一、引言
相信刚学习Java并发编程的很多童鞋都有这样的感受,并发编程的知识体系非常庞大,知识点多,特别是JUC并发包里面各种复杂的组件:锁、原子变量、线程池和Doug Lea大神精心打造的AQS框架, 一不小心就会迷失在其中繁杂的细节,很多文章和教程一来就开始讲用法甚至是源码,看完后似懂非懂,而且很容易就会忘记。
本文来深入剖析一下Java并发体系的一项关键技术——锁,并借助自定义锁来深入理解Java并发体系的核心原理。
二、锁的引入和多线程问题
首先来思考一下为什么需要锁?
相信很多了解并发的童鞋都能说得出来:通过锁进行互斥控制,解决并发问题。
是的,这是它的核心。 锁的提出主要是为了保障多线程的并发访问共享资源的安全性。
在了解锁是如何解决多线程并发安全问题之前,我们先来回顾一下多线程技术,思考一下多线程的优势有哪些,与此同时又会带来哪些问题呢?
2.1 多线程的定义
多线程是一种编程和执行模型,它允许在一个进程的上下文中存在多个线程。 这些线程共享了该进程的资源,又能够独立执行。 简单的说就是在一个应用中同时开启多个线程来执行任务,这些线程可以共享此应用的内存等资源。
那么多线程的优势是什么,是什么让多线程技术显得如此重要?
2.2 多线程的优势
1.充分利用CPU资源 。 如果一个线程正在等待网络传输的结果,那么另一线程可以同时使用CPU来执行其他操作。特别是在现代多CPU的计算机上,这样可以充分发挥CPU的计算能力。
2.提高用户程序请求响应速度。处理请求的线程通过将任务分派到后台工作线程来处理,然后无需等待任务完成便可响应结果,这种异步处理的方式可以极大提升程序响应能力。
3.提供更公平的任务执行策略。 通过在不同CPU时间片间对线程进行切换,尽可能保证不同的线程任务都能得到执行的机会。
4.提供更简单的编程模型。 这里的"更简单"是指不同职责的任务通过不同的线程来实现,线程的切换调度则交由操作系统来控制,职责单一,更加简单清晰。
任何问题都可以辩证地去思考,计算机技术更是如此,多线程在给程序带来极大优势的同时,与之也带来了一系列的问题。
2.3多线程带来的问题
1.安全性问题
这是多线程程序开发中最常见的问题,也是并发核心技术重点需要解决的问题。
多线程和单线程最大的区别是:多线程的执行顺序是不可预测的,不可被假定,一旦错误假定了程序是按照某种特定的顺序来执行,就可能出现各种不正常的结果。
而安全性问题就是指多线程访问时程序表现出不正确的行为。
例如程序开发过程中最常碰到的“读改写”问题,多个线程同时查询某一共享变量(x=1)后,进行修改(x=x+1), 再并写入数据(x=2),这时就可能出现数据更新丢失的异常现象。
2.活跃性问题
死锁、 活锁 、饥饿
3.性能问题
线程的切换带来的开销、使用锁同步带来的开销、内存共享(伪共享)
而以上问题中,锁解决的就是安全性问题,也是本文重点介绍关注的问题。
这里特别指出的一点是,需要培养一种意识:一看到“读改写”或“检查后执行”这一类的复合操作,就要考虑到是否会有多线程安全问题的隐患。
下面我们来分析一下导致出现安全性问题的原因。
2.3.1 出现安全性问题的原因
由于多线程访问修改了同一个可变的共享变量——这是前提。在这个前提之下,如果没有对该变量进行正确的同步访问控制时,会由于以下的原因,产生安全性问题。
1.没有保证原子性 原子性:一组操作要么全部执行要么全部不执行。 由于操作系统会对CPU中执行的线程进行切换,这时会导致CPU中某个线程的操作还没执行完成就切换到另外的线程执行,无法在CPU指令层面得到原子性的保障,从而出现原子性问题。
2.没有保证可见性 可见性:一个线程对特定共享变量的修改,对于其他线程是可以立即看到的。 由于多核CPU存在各自高速缓存Cache,当读写操作都在高速缓存中进行时,如果没有保障内存和缓存一致性,就会导致可见性问题。
3.没有保证有序性 有序性:为了提高程序性能,编译器层面常常会对指令进行重排序,甚至在更深的层次,CPU的流水线也会对指令进行重排序。 而由于编译器和CPU的指令重排序,程序的执行并不会完全按照程序指令的书面顺序一样执行,这时可能会导致有序性问题,引发程序执行的异常。
因此,要想解决安全性问题,就需要对以上三大原因采取针对性的措施。
2.3.2 解决安全性问题的措施
上面分析原因时有提到,出现并发安全性问题原因的前提是由于多线程访问修改了同一个可变的共享变量,然后才是未进行同步导致。
那么首先针对前提, 如果不在线程之间进行变量共享,或者共享的变量是不可变的,那么就不会出现安全性问题。
所以在这种场景下,可以利用ThreadLocal将变量封闭在线程中(保证变量无共享),也可以将变量定义final(保证变量不变性)。
其次,如果在难以改变此前提的情况下,我们就需要解决上面所说的三个问题:
那么Java并发编程体系是通过哪些关键技术来解决这三个问题的呢,下文将会重点介绍。
三、解决安全性三大问题的关键技术剖析
3.1 解决原子性问题——CAS无锁算法和底层CPU实现原理
CAS :compare-and-swap(比较并交换),通过将内存中的值与传入的参数进行比较,判断数值一致时才将内存中的数据替换为参数值。 CAS是一种算法,也是一种思想或者一种解决方案。
CAS是并发编程中的一大利器,包括大家熟悉的Java语言级别的互斥锁synchronized和JUC并发包中很多的并发工具类都直接利用到了CAS。
那么CAS是如何解决原子性问题的呢?
CPU的硬件实现 不同的CPU指令集的实现不一样,在IA64/x86 CPU指令集中是利用CMPXCHG这一指令来实现CAS功能。 实际上真正执行的是 LOCK CMPXCHAG 指令,因为CMPXCHAG并不是原子性的,必须要通过LOCK前缀声明,CPU才能保证被LOCK前缀修饰的指令是原子性的。
而LOCK前缀的指令是通过总线锁定和缓存锁定两个机制来实现的。CPU可以根据不同场景选择不同的机制来实现锁定。
1.总线锁定
当某个CPU执行上述带有Lock前缀的指令时,会在BUS总线上发出一个Lock信号,总线仲裁器会将总线的通信由该CPU独占锁定,锁定期间其余的CPU核心不能再通过总线与内存通讯,因而无法访问内存,达到资源独占实现原子性执行的目的。
总线锁定的缺点是在阻塞其它CPU获取该共享变量的操作请求时,也可能会导致其他内存访问的大量阻塞,从而增加系统的开销。
2.缓存锁定
由于总线锁定存在开销严重的问题,在某些场景下:比如频繁使用的内存会缓存在CPU的高速缓存中,当多个CPU同时执行针对某同一地址的CAS指令时,其实他们是在试图修改自己持有的高速缓存行数据,
此时利用缓存一致性协议,再借助总线仲裁器来实现只允许一个CPU成功完成操作。
可以看出,无锁算法也并没有完全消除锁同步的,而是避免了应用语言层面的高级锁带来的不确定时间的线程等待,将最小的单个指令代码转嫁到CPU底层硬件指令来进行统一同步阻塞控制,保证原子性操作。
因而有了CPU提供的硬件底层支持,操作系统和应用层就可以利用CAS来实现原子性操作了。
而应用程序在使用CAS的时候必须注意以下几个问题: 1.ABA问题。由于CAS命令本身只是检查了值是否一致,这时可能出现一个值原来是A,变成了B,又变成了A。这种场景下应用程序可以对变量增加版本号进行校验,典型的案例就是JUC包中的AtomicStampedReference。 2.循环时间长时开销大,特别是高并发修改时,对某个变量重复CAS,效率很低,这时可以采用拆分的思想,将一个变量拆分成多个,减少CAS的冲突,这也是JUC中LongAdder比AtomicLong在高并发场景下效率更好的原因。
然而光有CAS的加持还不够,只保证了原子性依然也还有可能发生并发安全问题。 比如著名的单例模式中的DCL双重加锁仍然存在并发问题。
尽管程序使用了synchronized来进行了同步控制,保证了原子性,但是由于 singleton = new Singleton(); 是分为了三个步骤: 1.在内存中开辟一块地址。 2.对象初始化。 3.将指针指向这块内存地址。 不巧的是这三个步骤有可能被编译器进行指令重排序,导致线程1对象还没有初始化时,先将singleton指针指向了内存中的地址, 这个时候线程2进来,判断singleton不为null, 直接返回了一个没有经过初始化的对象,程序会出现异常。
因此我们还需要关注另外两个因素:可见性和有序性。 在通过CAS利用CPU底层来解决了原子性问题之后,就需要高级语言层面来保障内存可见性和有序性。
3.2 解决有序性和可见性问题——Java内存模型和volatile变量的作用和原理
产生可见性的原因是由于缓存,而产生有序性的原因是编译系统的优化重排序。所以在需要的时候通过禁用缓存和编译重排序,就可以解决可见性和有序性问题。
了解了解决并发安全问题的关键技术,我们再来看看如何实现锁。
四、锁的特性和实现
4.1 锁的必备特性
- 同步和互斥
- 公平性
公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。 优点:所有的线程都能得到资源,不会饿死在队列中。 缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。
非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。 优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。 缺点:你们可能也发现了,这样可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死。
-
共享和独占 共享:同一时间只能有一个线程持有锁。 独占:允许多个线程同时获取锁,即可以并发访问共享资源。 -
阻塞和非阻塞 阻塞:多个线程竞争锁时,未能获取锁的线程进入等待状态。 非阻塞:多个线程竞争锁时,未能获取锁的线程直接返回。
4.2 实现锁的关键技术
1.利用volatile变量读写+CAS无锁算法来实现同步和互斥 2.利用条件队列和wait/notify机制实现线程的阻塞和唤醒
4.3 实现锁的基本步骤
1.锁的获取
2.队列阻塞和等待
3.锁的释放
五、总结
本文通过了解锁引入的背景原因和解决的问题来理解java并发编程中关于线程安全的控制的底层原理。后续文章将会继续深入分析《如何基于AQS实现简单的可重入锁》、《锁的扩展——分布式场景中的锁》
|