分享人:李文海
分享主题:Java中的【锁】事
分享提纲:
1、为什么要使用锁?
2、使用锁会带来什么问题?
3、各种锁在Java中的实现;
4、对锁进行优化设计思考;
李老师在架构师训练营第7周的课程中,介绍了操作系统的各种锁,也看到有同学在群里问每种锁是如何实现的,今天在这里跟大家分享一下。
首先,还是从问题出发,操作系统为什么要设计锁?锁用来解决什么问题?
这里就要先看看并发编程带来的问题;
先来看以下代码,这段代码在单线程环境下,累加多少次都会和我们预想的一致,但是在多线程环境下,这段代码计算结果也许会和预期的不一样;
当有两个线程同时执行上面的代码时,很可能会出现下图中的情况,预期count的值等于2,很有可能出现为1的情况;
因为count++并不是一个原子操作,对于count++来说,在指令级别是三个操作过程:
将上面的代码反编译后的结果,可以看到,count++这个操作在指令级别是三个独立的操作;
这就引出原子性问题,在并发编程中,原子性就是希望程序相关操作不会中途被其他线程干扰;
之前我们的作业中,大家都写过关于单例模式的代码,有一种实现单例模式的方法叫【延迟初始化】,在首次获取这个实例时创建对象,参考下面这段代码:
同样,在单线程环境下,上面的代码可以仅创建一个UnsafeLazyLoad实例,但是在多线程情况下,假如多个线程同时执行上面的代码,则会出现一种叫【竞态条件】的现象:基于某个失效的条件来执行某个计算;当两个线程同时执行getInstance这段代码,很可能都发现singleton为null,这时两个线程会同时new 两个对象,最后返回的是不同的实例。
CPU或者内存模型会对
在没有同步的情况下,还有一个问题,就是CPU、Java内存模型在运行期间的特殊处理,使不同线程对程序的操作顺序可能不同,这会导致程序执行的结果,和我们预期结果不一致,这种现象有个称为【指令重排序】。参考下面代码:
上面这段代码,预期输出的结果是【x = 0, y = 1】,在没有同步的多线程环境中,可能因为指令重排序输出不可预测的结果,如下图所示:
通过上面问题描述,如果没有很好的同步机制,在多线程环境下对共享可变数据的操作或者对程序的执行顺序,那么程序不能正确执行;原子性、可见性和有序性问题,都属于线程安全的问题,什么是线程安全呢?
这里引用《Java并发编程实战》这本书对【线程安全】的定义:
当多个线程访问某个类时,不管运行时环境采用何种调度方式,或者线程如何交替执行,并且主调代码 不需要任何额外的同步或协调操作 ,这个类都能表现出正确的行为,就称这个类是 线程安全。
为了保证线程安全,必须采用同步机制。
Java中,有很多锁来实现同步。
同步而Java实现
任何技术都不是银弹,锁可以实现同步保证线程安全,但是锁同样有问题,我们先看看使用锁会有哪些问题。
当使用锁时,多个线程间会出现上下文切换,并且锁保护的临界区代码,每次只能一个线程访问,自然将低程序的可伸缩性,程序正确性与性能之间,要找到合适的平衡,为了解决锁的性能问题,Java同样也提供优化方法。
接下来我们一起看看活跃性问题,活跃性问题通常包括以下几个方面:
所谓死锁,就是多个线程之间,不释放自己占有的资源,又得不到被其他线程获取的资源,线程之间因为相互等待,导致程序无法执行。
死锁的问题并不只出现在Java中,数据库在多个事务操作时,也会出现死锁的情况,而数据库解决死锁是通过事务间等待关系的有向图,判断是否存储死锁,并且选择放弃一个事务来解决。
在接下来的内容里,会介绍Java是如何解决死锁问题。
与死锁不同,活锁是当线程间出现竞争后,主动放弃资源(可以理解为主动谦让),导致程序无法执行的情况。
饥饿就是一个等待很久的线程,因为自身优先级不够高,导致在和其他线程的竞争中,始终获取不到锁,所以就一直【饿着】。
既然使用锁这些问题,不用又无法保证线程安全,Java在多个版本中一直在优化和解决这些问题,接下来我们就看看Java是如何实现各种锁,以及是如何解决上面提到的问题。
为了保证线程安全,Java本身有一些锁的实现;
解决锁产生的一系列问题,Java中也对这些问题做了很多优化;
接下来我们一起看看Java是如何实现各种锁的;
有些概念是老师在课上已经讲过的,但是为了更好的说明,这里再重新回顾一些概念;
1、概念:
对同一数据的并发操作,一定会发生修改;
悲观的认为,不加锁一定会发生问题;
2、悲观锁在Java中的实现
在Java中,常用的悲观锁:synchronized 和 ReentrantLock;
对于静态方法,锁的对象就是方法所在的类;
对于非静态方法,锁的对象就是调用方法的实例;
对于代码块,锁的对象就是synchrobized后面括号中的对象;
对于synchronized底层实现,通过反编译java代码,synchronzied是通过【monitorenter】和【monitorexit】两个指令实现,参考以下代码:
synchronized本身有很多问题,java也针对这些问题进行优化,在后续和大家介绍;
注意,如果在程序中使用ReentrantLock,一定要在业务逻辑代码中使用try {} finally{} 处理逻辑,并且在finally中调用unlock()方法释放锁;
除了lock()方法以外,ReentrantLock还提供了带有等待时间的tryLock方法,在实现上通过一个随机长度的等待时间,超过时间就释放锁,这就解决之前提到的【活锁】和【死锁】问题;
1、概念
对同一数据的并发操作,不会发生修改;更新时发现修改过,根据实现方式不同,可以进行报错或者重试;
2、乐观锁在Java中的实现
Java实现乐观锁,就是课上老师提到的【CAS原语】,Java中【sun.misc.Unsafe】这个类中提供的方法,而Unsafe提供的CAS方法底层实现是CPU指令cmpxchg;
相关方法如下,以compareAndSwapInt(Object var1, long var2, int var4, int var5)为例,包括四个参数,分别代表的含义是:
3、Unsafe提供的CAS方法在Java源代码使用
AtomicInteger方法的compareAndSet方法,其中valueOffset就是AtomicInteger对象在内存中的地址;
在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这样的悲观锁更好,但是一个原子类只能保证自己封装数据的原子性,假如在一个操作中,同时更新两个原子变量,也不能保证两个原子变量操作的线程安全,此时还是需要使用互斥对多个变量进行操作。
上面提到,互斥同步对性能最大的影响是阻塞的实现,因为挂起和恢复线程的操作都需要进入内核态中完成,这些操作给系统并发带来很大压力,对于临界区代码的操作耗时有可能比线程切换带来的耗时更短,Java的设计者认为共享数据的锁定状态只会持续很短的时间,为了很短时间对线程进行挂起和恢复似乎不太值得,如果线程获取不到资源,此时不放弃CPU,而是等待一段时间,看是否能获取资源,于是产生了自旋锁;
关于自旋锁的定义,来自《深入理解Java虚拟机》这本书:
多线程并行执行请求锁时,让后面请求锁的线程稍等一下,线程等待的方式,就是执行一个忙循环,但不放弃处理器执行时间,等待持有锁的线程释放锁,这项技术就是自旋锁。
Java在1.6开始使用【-XX:+UseSpinning】默认开启自旋锁。
自旋锁确实解决了因为线程切换带来的时间消耗问题,但自旋锁这项技术本身也带来了问题:如果锁长时间不被释放,自旋的线程会消耗CPU资源;
所以自旋的时间必须有一定限制,如果超过指定次数获取不到锁,就挂起线程。
Java中使用【-XX:PreBlockSpin】限制自旋次数,默认值为10。
自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。
如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
刚才我们看到,对于synchronized操作,在并发情况下,Java会将获取不到锁的线程挂起,synchronized代码性能较差的原因,从JDK 6起,为了减少获得锁和释放锁带来的性能消耗,引入了 偏向锁 和 轻量级锁。
偏向锁、轻量级锁 和 重量级锁 的概念,老师在课上已经跟大家介绍了,这里还是聊聊Java中的实现:
在Java中使用JVM参数【-XX:-UseBiasedLocking】配置来控制是否开启偏向锁,如果关闭,则程序将会使用轻量级锁。
偏向锁的设计,是考虑在大多数情况下,只会由一个线程访问同步代码块,不存在多线程竞争锁的情况,偏向锁就是为了提高只有一个线程访问同步代码块的性能。
而轻量级锁设计的目的,就是为了减少在并发情况下,线程获取不到锁时,通过自旋的方式获取锁,不会阻塞影响性能。
下图内容来自《深入理解Java虚拟机》
公平锁
优点:不会出现线程饥饿;
缺点:只有等待队列中第一个线程能被唤醒,其他线程都会被阻塞,因此系统的吞吐量较低;
公平锁解决了上面提到的【饥饿】问题;
非公平锁
优点:减少CPU唤起线程的开销,吞吐量较高;
缺点:会出现线程饥饿。
在Java中,synchronized是非公平锁,ReentrantLock默认是非公平锁,可以通过构造函数传入值true来实现公平锁,具体代码如下:
接下来,我们再看看公平锁和非公平锁的加锁方式
公平锁的加锁方式,
非公平锁的加锁方式,
通过源代码对比,我们可以明显的看出公平锁与非公平锁的lock()方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors(),
我们再看看hasQueuedPredecessors()的实现逻辑:
可以看到该方法主要做一件事情:主要是判断当前线程是否位于同步队列中的第一个。如果是则返回true,否则返回false。
可重入锁,就是指线程可以重复获得一个由他自己已经持有的锁;上面我们提到的偏向锁也属于可重入锁;
先看下面这段代码,在循环中处理页面逻辑,如果锁是不可重入的,那么线程在第二次循环向获取锁时,线程就会被阻塞,导致代码无法继续;如果一个锁是可重入的,意味着获取锁操作的粒度是【线程】;
在Java中,synchronized本身就是可重入的,关于可重入锁如何设计,《Java并发编程实战》这本书也给出了对于方法:
为每个锁关联一个【获取计数值】和一个【所有者线程】;
当计数值为0时,这个锁就被认为是没有被任何线程持有;
当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将【获取计数值】置为1;
如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减;
当计数值为0时,这个锁就被释放;
ReentrantLock也是一个可重入锁,它内部的Sync类继承AQS,通过维护一个state记录重入锁【获取计数值】,在AQS内部state用volatile定义,保证了可见性。下面这段代码,就是ReentrantLock如何实现可重入锁的逻辑。
对于非公平锁,Java并没有使用显示锁这种方式去实现,而是在线程池ThreadPoolExecutor中的Worker类,就被定义为【非重入锁】,我们看看Java是如何实现的。
非重入锁与重入锁的区别就是状态(state),也就是锁计数,只能设定1次,要么是0要么是1。
上面提到的 Synchronized 和 ReentrantLock,都是独享锁,也就是一个锁只能被一个线程持有。
但是在读多写少的场景,如果每次读取共享数据都要加独享锁,每次只能有一个线程访问,这种线性执行非常影响程序【性能】。
所以针对读多写少,Java中的ReadWriteLock,确保【多个执行读操作】的线程可以同时访问数据,从而提升性能。
读写锁的多个读线程并不互斥,只有写线程与其他线程互斥。
ReadWriteLock只是一个接口类,它的实现类是 ReentrantReadWriteLock,内部定义了两个锁,分布式代表读锁的ReadLock,和代表写锁的WriteLock。
同时内部还实现【公平锁】和【非公平锁】,是为了解决读写优先级的问题,并且也是可重入的。
我们看看读写锁时如何加锁的,它没有单独为读锁和写锁实现加锁逻辑,而是复用通过内部的Sync对象,其中state状态字段,因为是int类型,其中低16位用来表示写状态,而高16位用来记录读状态。有兴趣的同学可以讨论这样实现是否好,并且有没有更好的实现方法。
我们看看写锁的实现:
接下来看看读锁的实现:
以上就是Java代码层面对锁的实现,针对锁的性能优化,除了提到基于CAS操作的原子类,并发容器外,还有一些方法可以提高使用锁时的性能。
就是将锁的数据分成多个段,每次只锁部分,这个是在JDK1.7之前ConcurrentHashMap实现的方法。
在程序代码中,我们通过减小锁的范围,来提升程序的性能,这样可以有效降低竞争发生的可能,减少串行执行的时间。锁的范围必须保证原子性(比如多个变量更新维持一个不变性条件的操作)
也是就是为多个独立的变量用多个锁进行管理,比如统计用户登录和用户下单的数量,这两个数据互不影响,就可以用不同的锁区保护他们,这里需要注意的是,一个共享变量只能被一个锁保护,而一个锁可以保护多个共享变量。
以上就是我分享的内容,Java本身提供多种安全并且性能较好的锁实现方案,大部分情况下我们不需要自己再实现锁,站在架构的角度,大家可以一起探讨Java的代码设计,包括锁、线程池等等。