架构3班 李文海
2020年8月10日 更新
开启更多功能,提升办公效能

分享人:李文海

分享主题:Java中的【锁】事

分享提纲:

1、为什么要使用锁?

2、使用锁会带来什么问题?

3、各种锁在Java中的实现;

4、对锁进行优化设计思考;


李老师在架构师训练营第7周的课程中,介绍了操作系统的各种锁,也看到有同学在群里问每种锁是如何实现的,今天在这里跟大家分享一下。


1、为什么要使用锁?

首先,还是从问题出发,操作系统为什么要设计锁?锁用来解决什么问题?

这里就要先看看并发编程带来的问题;


1-1、原子性问题

先来看以下代码,这段代码在单线程环境下,累加多少次都会和我们预想的一致,但是在多线程环境下,这段代码计算结果也许会和预期的不一样;

private int count;

public void add() {
count++;
}


当有两个线程同时执行上面的代码时,很可能会出现下图中的情况,预期count的值等于2,很有可能出现为1的情况;



因为count++并不是一个原子操作,对于count++来说,在指令级别是三个操作过程:

  1. 获取count的值;
  1. 对count的值+1;
  1. 将计算结果重新赋值给count;

将上面的代码反编译后的结果,可以看到,count++这个操作在指令级别是三个独立的操作


这就引出原子性问题,在并发编程中,原子性就是希望程序相关操作不会中途被其他线程干扰;


1-2、可见性问题

之前我们的作业中,大家都写过关于单例模式的代码,有一种实现单例模式的方法叫【延迟初始化】,在首次获取这个实例时创建对象,参考下面这段代码:

public class UnsafeLazyLoad {

private static UnsafeLazyLoad singleton;

public static UnsafeLazyLoad getInstance() {
if (singleton == null) {
singleton = new UnsafeLazyLoad();
}
return singleton;
}

}


同样,在单线程环境下,上面的代码可以仅创建一个UnsafeLazyLoad实例,但是在多线程情况下,假如多个线程同时执行上面的代码,则会出现一种叫【竞态条件】的现象:基于某个失效的条件来执行某个计算;当两个线程同时执行getInstance这段代码,很可能都发现singleton为null,这时两个线程会同时new 两个对象,最后返回的是不同的实例。


1-3、有序性问题

CPU或者内存模型会对

在没有同步的情况下,还有一个问题,就是CPU、Java内存模型在运行期间的特殊处理,使不同线程对程序的操作顺序可能不同,这会导致程序执行的结果,和我们预期结果不一致,这种现象有个称为【指令重排序】。参考下面代码:

public class PossibleReordering {

static int x = 0, y = 0;

static int a = 0, b = 0;

public static void main(String[] args) throws InterruptedException {
Thread thread0 = new Thread(() -> {
a = 1;
x = b;
});
Thread thread1 = new Thread(() -> {
b = 1;
y = a;
});
thread0.start();
thread1.start();
thread0.join();
thread1.join();
System.out.println("x = " + x + ", y = " + y);
}
}


上面这段代码,预期输出的结果是【x = 0, y = 1】,在没有同步的多线程环境中,可能因为指令重排序输出不可预测的结果,如下图所示:



1-4、什么是线程安全?

通过上面问题描述,如果没有很好的同步机制,在多线程环境下对共享可变数据的操作或者对程序的执行顺序,那么程序不能正确执行;原子性、可见性和有序性问题,都属于线程安全的问题,什么是线程安全呢?


这里引用《Java并发编程实战》这本书对【线程安全】的定义:

当多个线程访问某个类时,不管运行时环境采用何种调度方式,或者线程如何交替执行,并且主调代码 不需要任何额外的同步或协调操作 ,这个类都能表现出正确的行为,就称这个类是 线程安全。


为了保证线程安全,必须采用同步机制。

Java中,有很多锁来实现同步。

同步而Java实现


2、锁带来的问题

任何技术都不是银弹,锁可以实现同步保证线程安全,但是锁同样有问题,我们先看看使用锁会有哪些问题。


2-1、性能问题

当使用锁时,多个线程间会出现上下文切换,并且锁保护的临界区代码,每次只能一个线程访问,自然将低程序的可伸缩性,程序正确性与性能之间,要找到合适的平衡,为了解决锁的性能问题,Java同样也提供优化方法。


2-2、活跃性问题

接下来我们一起看看活跃性问题,活跃性问题通常包括以下几个方面:


2-2-1、死锁

所谓死锁,就是多个线程之间,不释放自己占有的资源,又得不到被其他线程获取的资源,线程之间因为相互等待,导致程序无法执行。

死锁的问题并不只出现在Java中,数据库在多个事务操作时,也会出现死锁的情况,而数据库解决死锁是通过事务间等待关系的有向图,判断是否存储死锁,并且选择放弃一个事务来解决。

在接下来的内容里,会介绍Java是如何解决死锁问题。


2-2-2、活锁

与死锁不同,活锁是当线程间出现竞争后,主动放弃资源(可以理解为主动谦让),导致程序无法执行的情况。


2-2-3、饥饿

饥饿就是一个等待很久的线程,因为自身优先级不够高,导致在和其他线程的竞争中,始终获取不到锁,所以就一直【饿着】。


既然使用锁这些问题,不用又无法保证线程安全,Java在多个版本中一直在优化和解决这些问题,接下来我们就看看Java是如何实现各种锁,以及是如何解决上面提到的问题。


3、各种锁在Java中的实现

为了保证线程安全,Java本身有一些锁的实现;

解决锁产生的一系列问题,Java中也对这些问题做了很多优化;

接下来我们一起看看Java是如何实现各种锁的;

有些概念是老师在课上已经讲过的,但是为了更好的说明,这里再重新回顾一些概念;


3-1、乐观锁和悲观锁

3-1-1、悲观锁

1、概念

对同一数据的并发操作,一定会发生修改;

悲观的认为,不加锁一定会发生问题;


2、悲观锁在Java中的实现

在Java中,常用的悲观锁:synchronized 和 ReentrantLock;


  • synchronized在代码中的使用,如代码所示:

对于静态方法,锁的对象就是方法所在的类;

对于非静态方法,锁的对象就是调用方法的实例;

对于代码块,锁的对象就是synchrobized后面括号中的对象;

// 修饰方法
public synchronized void function() {}

// 修饰静态方法
public static synchronized void function() {}

// 对方法块的修饰
Object obj = new Object();
public void function() {
synchronized(obj) {
}
}


对于synchronized底层实现,通过反编译java代码,synchronzied是通过【monitorenter】和【monitorexit】两个指令实现,参考以下代码:



synchronized本身有很多问题,java也针对这些问题进行优化,在后续和大家介绍;


  • 通过ReentrantLock实现悲观锁:

注意,如果在程序中使用ReentrantLock,一定要在业务逻辑代码中使用try {} finally{} 处理逻辑,并且在finally中调用unlock()方法释放锁;

private final ReentrantLock lock = new ReentrantLock();

public void update() {
lock.lock();
try {
// 业务逻辑处理
} finally {
lock.unlock();
}
}


除了lock()方法以外,ReentrantLock还提供了带有等待时间的tryLock方法,在实现上通过一个随机长度的等待时间,超过时间就释放锁,这就解决之前提到的【活锁】和【死锁】问题;


3-1-2、乐观锁

1、概念

对同一数据的并发操作,不会发生修改;更新时发现修改过,根据实现方式不同,可以进行报错或者重试;


2、乐观锁在Java中的实现

Java实现乐观锁,就是课上老师提到的【CAS原语】,Java中【sun.misc.Unsafe】这个类中提供的方法,而Unsafe提供的CAS方法底层实现是CPU指令cmpxchg;

相关方法如下,以compareAndSwapInt(Object var1, long var2, int var4, int var5)为例,包括四个参数,分别代表的含义是:

  1. Object obj : 要进行CAS操作值所在的对象;
  1. long var2: 对象中某个属性的内存地址;
  1. int var4 :预期值;
  1. int var5:新值;


3、Unsafe提供的CAS方法在Java源代码使用

  • 原子类 - AtomicInteger

AtomicInteger方法的compareAndSet方法,其中valueOffset就是AtomicInteger对象在内存中的地址;


  • ConcurrentHashMap

在ConcurrentHashMap的putVal方法中,根据Key找到元素在Hash表中的位置,使用的就是CAS原语,这也是为什么ConcurrentHashMap性能较好的原因,具体代码以下代码:




4、ABA问题

如果一个变量V初次读取的时候是A值,赋值准备的时候检查期间,值曾经被改成B,后来又被改回A,CAS操作会误认为这个值从没有改变过,也是就是【A-B-A】这样的现象,所以这个漏洞称为CAS操作的【ABA问题】;


解决ABA的问题很简单,就是在更新时使用不是一个引用,而是两个:一个是期望值,另一个是版本号,用【值 + 版本号】的方式更新;


Java从1.5版本,通过引入AtomicStampedReference来解决ABA问题,具体实现参考compareAndSet方法,其中除了【期望的引用】和【当前引用】是否相等,还增加了【预期标志】与【当前标记】是否相等的判断:


我们可以思考一下,同样的问题,在并发更新数据库中的数据时,是否也会出现?

以MySQL为例,默认的隔离级别是可重复读,假如有两个请求在各自的事务中同时去更新一条数据,如果A事务更新成功并提交,后面B事务也更新同样的数据,那么A事务更新的数据就被覆盖掉了,举一个贴近现实的例子:

A和B两个账户同时给账户C转账,A和B转账是两个事务,A给C转账更新账户C的余额,接着B的转账也更新账户C的余额,B的事务就会把之前A的更新给覆盖。

所以通常在数据库对同一个数据更新,会使用【主键+版本号】的方式,避免不同事务间并发修改带来的问题;


通过以上分析,为在并发情况更新数据,提供一种简单的设计方案,通过【值 + 版本号】的方式,可以避免并发环境数据更新带来的覆盖问题;


5、关于同时多个原子数据的问题

保证虽然原子类在性能上比synchronized这样的悲观锁更好,但是一个原子类只能保证自己封装数据的原子性,假如在一个操作中,同时更新两个原子变量,也不能保证两个原子变量操作的线程安全,此时还是需要使用互斥对多个变量进行操作。


3-2、自旋锁和自适应自旋锁

3-2-1、自旋锁

上面提到,互斥同步对性能最大的影响是阻塞的实现,因为挂起和恢复线程的操作都需要进入内核态中完成,这些操作给系统并发带来很大压力,对于临界区代码的操作耗时有可能比线程切换带来的耗时更短,Java的设计者认为共享数据的锁定状态只会持续很短的时间,为了很短时间对线程进行挂起和恢复似乎不太值得,如果线程获取不到资源,此时不放弃CPU,而是等待一段时间,看是否能获取资源,于是产生了自旋锁;


关于自旋锁的定义,来自《深入理解Java虚拟机》这本书:

多线程并行执行请求锁时,让后面请求锁的线程稍等一下,线程等待的方式,就是执行一个忙循环,但不放弃处理器执行时间,等待持有锁的线程释放锁,这项技术就是自旋锁


Java在1.6开始使用【-XX:+UseSpinning】默认开启自旋锁。


自旋锁确实解决了因为线程切换带来的时间消耗问题,但自旋锁这项技术本身也带来了问题:如果锁长时间不被释放,自旋的线程会消耗CPU资源;

所以自旋的时间必须有一定限制,如果超过指定次数获取不到锁,就挂起线程。

Java中使用【-XX:PreBlockSpin】限制自旋次数,默认值为10。


3-2-1、自适应自旋锁

自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。

如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。


3-3、偏向锁、轻量级锁和重量级锁

刚才我们看到,对于synchronized操作,在并发情况下,Java会将获取不到锁的线程挂起,synchronized代码性能较差的原因,从JDK 6起,为了减少获得锁和释放锁带来的性能消耗,引入了 偏向锁  和 轻量级锁。


偏向锁、轻量级锁 和 重量级锁 的概念,老师在课上已经跟大家介绍了,这里还是聊聊Java中的实现:

在Java中使用JVM参数【-XX:-UseBiasedLocking】配置来控制是否开启偏向锁,如果关闭,则程序将会使用轻量级锁。


偏向锁的设计,是考虑在大多数情况下,只会由一个线程访问同步代码块,不存在多线程竞争锁的情况,偏向锁就是为了提高只有一个线程访问同步代码块的性能。


而轻量级锁设计的目的,就是为了减少在并发情况下,线程获取不到锁时,通过自旋的方式获取锁,不会阻塞影响性能。


下图内容来自《深入理解Java虚拟机》


3-4、公平锁和非公平锁

公平锁

优点:不会出现线程饥饿;

缺点:只有等待队列中第一个线程能被唤醒,其他线程都会被阻塞,因此系统的吞吐量较低;

公平锁解决了上面提到的【饥饿】问题;


非公平锁

优点:减少CPU唤起线程的开销,吞吐量较高;

缺点:会出现线程饥饿。


在Java中,synchronized是非公平锁,ReentrantLock默认是非公平锁,可以通过构造函数传入值true来实现公平锁,具体代码如下:


接下来,我们再看看公平锁和非公平锁的加锁方式

公平锁的加锁方式,


非公平锁的加锁方式,


通过源代码对比,我们可以明显的看出公平锁与非公平锁的lock()方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors(),

我们再看看hasQueuedPredecessors()的实现逻辑:

可以看到该方法主要做一件事情:主要是判断当前线程是否位于同步队列中的第一个。如果是则返回true,否则返回false。


3-5、可重入锁和不可重入锁

可重入锁,就是指线程可以重复获得一个由他自己已经持有的锁;上面我们提到的偏向锁也属于可重入锁;

先看下面这段代码,在循环中处理页面逻辑,如果锁是不可重入的,那么线程在第二次循环向获取锁时,线程就会被阻塞,导致代码无法继续;如果一个锁是可重入的,意味着获取锁操作的粒度是【线程】;

public void cycleAdd() {
for (int i = 0; i < 1000; i++) {
synchronized (this) {
// 业务逻辑
}
}
}


在Java中,synchronized本身就是可重入的,关于可重入锁如何设计,《Java并发编程实战》这本书也给出了对于方法:

为每个锁关联一个【获取计数值】和一个【所有者线程】;
当计数值为0时,这个锁就被认为是没有被任何线程持有;
当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将【获取计数值】置为1;
如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减;
当计数值为0时,这个锁就被释放;


ReentrantLock也是一个可重入锁,它内部的Sync类继承AQS,通过维护一个state记录重入锁【获取计数值】,在AQS内部state用volatile定义,保证了可见性。下面这段代码,就是ReentrantLock如何实现可重入锁的逻辑。

abstract static class Sync extends AbstractQueuedSynchronizer {
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// 如果状态(计数值)为0,尝试获取锁
if (compareAndSetState(0, acquires)) {
// 通过CAS进行比较,表明线程可以获得锁
// 设置当前线程为获得锁的线程
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
// 如果获取锁就是线程本身,则状态值+1
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}

protected final boolean tryRelease(int releases) {
// 解锁时,对计数值-1
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
// 当计数值为0时,释放锁,并将原有记录的线程置为null
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
}


对于非公平锁,Java并没有使用显示锁这种方式去实现,而是在线程池ThreadPoolExecutor中的Worker类,就被定义为【非重入锁】,我们看看Java是如何实现的。

protected boolean tryAcquire(int unused) {
if (compareAndSetState(0, 1)) {
// 设置状态成功,当前线程获得,同一个线程向再次获取也无法获得锁
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}

protected boolean tryRelease(int unused) {
// 释放锁时直接将线程设为null,锁的计数为0
setExclusiveOwnerThread(null);
setState(0);
return true;
}


非重入锁与重入锁的区别就是状态(state),也就是锁计数,只能设定1次,要么是0要么是1。


3-6、独享锁、共享锁 和 读写锁

上面提到的 Synchronized 和 ReentrantLock,都是独享锁,也就是一个锁只能被一个线程持有。

但是在读多写少的场景,如果每次读取共享数据都要加独享锁,每次只能有一个线程访问,这种线性执行非常影响程序【性能】。

所以针对读多写少,Java中的ReadWriteLock,确保【多个执行读操作】的线程可以同时访问数据,从而提升性能。

读写锁的多个读线程并不互斥,只有写线程与其他线程互斥。


ReadWriteLock只是一个接口类,它的实现类是 ReentrantReadWriteLock,内部定义了两个锁,分布式代表读锁的ReadLock,和代表写锁的WriteLock。

同时内部还实现【公平锁】和【非公平锁】,是为了解决读写优先级的问题,并且也是可重入的。




我们看看读写锁时如何加锁的,它没有单独为读锁和写锁实现加锁逻辑,而是复用通过内部的Sync对象,其中state状态字段,因为是int类型,其中低16位用来表示写状态,而高16位用来记录读状态。有兴趣的同学可以讨论这样实现是否好,并且有没有更好的实现方法。


我们看看写锁的实现:

static final int SHARED_SHIFT   = 16;
static final int SHARED_UNIT    = (1 << SHARED_SHIFT);
static final int MAX_COUNT      = (1 << SHARED_SHIFT) - 1;
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;
// 读锁可以理解为共享锁
static int sharedCount(int c)    { return c >>> SHARED_SHIFT; }
// 写锁是个排他锁
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }

final boolean tryWriteLock() {
    Thread current = Thread.currentThread();
    int c = getState(); // 获取当前锁的数量
    if (c != 0) {
        int w = exclusiveCount(c); // 获取写锁的数量
         if (w == 0 || current != getExclusiveOwnerThread())
// 写锁等于0,但是不是当前线程持有不能加锁
          return false;
         if (w == MAX_COUNT) // 写锁超过65535, Error
             throw new Error("Maximum lock count exceeded");
    }
    if (!compareAndSetState(c, c + 1))
        return false;
    setExclusiveOwnerThread(current);
    return true;
}


接下来看看读锁的实现:

final boolean tryReadLock() {
Thread current = Thread.currentThread();
for (;;) {
int c = getState();
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current) //写锁存在则不能加锁
return false;
int r = sharedCount(c);
if (r == MAX_COUNT)
throw new Error("Maximum lock count exceeded");
if (compareAndSetState(c, c + SHARED_UNIT)) {
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
firstReaderHoldCount++;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
}
return true;
}
}
}



4、针对锁的使用如何进行优化

以上就是Java代码层面对锁的实现,针对锁的性能优化,除了提到基于CAS操作的原子类,并发容器外,还有一些方法可以提高使用锁时的性能。


4-1、分段锁

就是将锁的数据分成多个段,每次只锁部分,这个是在JDK1.7之前ConcurrentHashMap实现的方法。


4-2、减小锁的范围

在程序代码中,我们通过减小锁的范围,来提升程序的性能,这样可以有效降低竞争发生的可能,减少串行执行的时间。锁的范围必须保证原子性(比如多个变量更新维持一个不变性条件的操作)


4-3、减小锁的粒度

也是就是为多个独立的变量用多个锁进行管理,比如统计用户登录和用户下单的数量,这两个数据互不影响,就可以用不同的锁区保护他们,这里需要注意的是,一个共享变量只能被一个锁保护,而一个锁可以保护多个共享变量。



以上就是我分享的内容,Java本身提供多种安全并且性能较好的锁实现方案,大部分情况下我们不需要自己再实现锁,站在架构的角度,大家可以一起探讨Java的代码设计,包括锁、线程池等等。