注:本笔记是阅读《Java高并发核心编程卷2》整理的笔记!
导致并发修改的原因
例如Java中的i++等指令并非是原子操作,而是三条指令的集合:“内存取值”、“寄存器增加1”、“存值到内存” 。 因此,如果是多线程并发使用CPU,当某个线程正在计算时被抢夺了CPU然后恰好被另外一个线程修改了变量将发生覆盖。根本原因是CPU上下文切换导致指令交错执行!
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i
基本概念
临界资源:一次仅允许一个进程使用的资源成为临界资源,一旦临界区资源被占用,想使用该资源的其他线程则必须等待。
临界区:访问临界资源的代码块。线程进入临界区代码段之前,必须在进入区申请资源,申请成功之后进行临界区代码段,执行完成之后释放资源。临界区代码段的进入和退出具体如图2-1所示。
竞态条件:多个线程没有互斥访问临界区,并发在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件。
为了避免竞态条件的问题,我们必须保证临界区代码段操作必须具备排他性 。一个线程进入Critical Section执行时,其他线程不能进入临界区代码段执行。
synchronized 关键字
每个Java对象都隐含有一把锁,这里称为Java内置锁|对象锁 。使用synchronized(syncObject)调用相当于获取syncObject的内置锁,所以可以使用内置锁对临界区代码段进行排他性保护。
方法声明synchronized
static int amount = 0;
public synchronized void selfPlus()
{
amount++;
}
在方法声明中设置synchronized同步关键字,保证了其方法的代码执行流程是排他性的。任何时间只允许一条线程进入同步方法(临界区代码段),如果其他线程都需要执行同一个方法,那么只能等待和排队。 使用this对象锁作为进入临界区的同步锁 。也就是说方法上声明synchronized 锁是当前对象。
synchronized 同步块
为了提升吞吐量,可以将synchronized关键字放在函数体内,同步一个代码块。 synchronized同步块的写法是:
synchronized(syncObject) //同步块而不是方法
{
//临界区代码段的代码块
}
synchronized同步块后边的括号中是一个syncObject对象,代表着进入临界区代码段需要获取syncObject对象的监视锁,或者说将syncObject对象监视锁作为临界区代码段的同步锁。由于每一个Java对象都有一把监视锁( Monitor),因此任何Java对象都能作为synchronized的同步锁。 synchronized代码块比synchronized方法更加细粒度地控制了多条线程的同步访问 。同步锁需要手动给定 syncObject 。如果某个synchronized方法是static(静态)方法,synchronized的同步锁并不是普通Object对象的监视锁,而是类所对应的Class对象的监视锁。
通过synchronized关键字所抢占的同步锁,什么时候释放呢?一种场景是synchronized块(代码块或者方法)正确执行完毕,监视锁自动释放;另一种场景是程序出现异常,非正常退出synchronized块,监视锁也会自动释放。所以,使用synchronized块时不必担心监视锁的释放问题。
消费者生产者问题
生产者―消费者问题关键是:
1)保证生产者不会在缓冲区满时加入数据,消费者也不会在缓冲区中为空时消耗数据。
2)保证在生产者加入过程、消费者消耗过程中,不会产生错误的数据和行为。
这里需要保证两个地方的互斥访问,一个是缓冲区,一个是记录缓存区的变量count。但是粗暴的使用对象锁作为同步锁,这样一来,所有的生产、消费动作在执行过程中都需要抢占同一个同步锁,最终的结果是所有的生产、消费动作都被串行化了导致效率低下。如何开发出并行化程度更高的生产者-消费者模式实现版本 ?后续就是围绕这个问题来讲解的,先从基础知识开始讲的。
Java 对象结构与内置锁
Java内置锁的很多重要信息都存放在对象结构中 ,不同的JVM的对象结构的实现不一样,这里以HotSpot JVM为例。 Java对象( Object实例)结构包括三部分:对象头、对象体和对齐字节。 对象头包括三个字段,第一个字段叫作Mark Word(标记字),用于存储自身运行时的数据,例如GC标志位、哈希码、锁状态等信息。
Mark Word(标记字)字段主要用来表示对象的线程锁状态,另外还可以用来配合GC、存放该对象的hashCode。 Mark Word的位长度为JVM的一个Word大小,也就是说32位JVM的Mark Word为32位, 64位JVM的Mark Word为64位。
Java内置锁的状态总共有4种,级别由低到高依次为:无锁、偏向锁、轻量级锁和重量级锁。其实在JDK 1.6之前, Java内置锁还是一个重量级锁,是一个效率比较低下的锁,在JDK 1.6之后, JVM为了提高锁的获取与释放效率,对synchronized的实现进行了优化,引入了偏向锁、轻量级锁的实现,从此以后Java内置锁的状态就有了4种(无锁、偏向锁、轻量级锁和重量级锁),并且4种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级,也就是说只能进行锁升级(从低级别到高级别)。
- 4位的Java对象分代年龄。在GC中,如果对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,因此最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因。
- identity_hashcode: 31位的对象标识HashCode(哈希码)采用延迟加载技术,当调用Object.hashCode()方法计算对象的HashCode后,其结果将被写到该对象头中。
- thread: 54位的线程ID值为持有偏向锁的线程ID。
- lock:锁状态标记位,占两个二进制位。
- biased_lock:对象是否启用偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。
四种内置锁
在JDK 1.6版本之前,所有的Java内置锁都是重量级锁。重量级锁会造成CPU在用户态和核心态之间频繁切换,所以代价高、效率低。 JDK 1.6版本为了减少获得锁和释放锁所带来的性能消耗,引入了“偏向锁”和“轻量级锁”实现。所以,在JDK 1.6版本里内置锁一共有4种状态:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这些状态随着竞争情况逐渐升级。内置锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种能升级却不能降级的策略,其目的是为了提高获得锁和释放锁的效率。
-
无锁状态
Java对象未加锁状态就是无锁状态,例如对象刚刚创建,偏向锁标识位是0、锁状态01 。
-
偏向锁状态
偏向锁是指一段同步代码一直被同一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。如果内置锁处于偏向状态,当有一个线程来竞争锁时,先用偏向锁,表示内置锁偏爱这个线程,这个线程要执行该锁关联的同步代码时,不需要再做任何检查和切换。偏向锁在竞争不激烈的情况下效率非常高。偏向锁状态的Mark Word会记录内置锁自己偏爱的线程ID,如下,内置锁会将该线程当作自己的熟人。
-
轻量级锁状态
当有两个线程开始竞争这个锁对象时,情况发生变化了,不再是偏向(独占)锁了,锁会升级为轻量级锁,两个线程公平竞争,哪个线程先占有锁对象,锁对象的Mark Word就指向哪个线程的栈帧中的锁记录。 当锁处于偏向锁又被另一个线程所企图抢占时,偏向锁就会升级为轻量级锁。企图抢占的线程会通过自旋的形式尝试获取锁,不会阻塞抢锁线程,以便提高性能。
自旋原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核切换的消耗。但是,线程自旋是需要消耗 CPU的,如果一直获取不到锁,那线程也不能一直占用CPU自旋做无用功,所以需要设定一个自旋等待的最大时间。JDK 1.6之后引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不是固定的,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定的。线程如果自旋成功了,下次自旋的次数就会更多,如果自旋失败了,自旋的次数就会减少。如果持有锁的线程执行的时间超过自旋等待的最大时间仍没有释放锁,就会导致其他争用锁的线程在最大等待时间内还是获取不到锁,自旋不会一直持续下去,这时争用线程会停止自旋进入阻塞状态,该锁膨胀为重量级锁。
-
重量级锁状态
重量级锁会让其他申请的线程之间进入阻塞,性能降低。重量级锁也就叫同步锁,这个锁对象Mark Word再次发生变化,会指向一个监视器对象,该监视器对象用集合的形式来登记和管理排队的线程。
偏向锁原理
偏向锁主要解决无竞争下的锁性能问题,所谓的偏向就是偏心,即锁会偏向于当前已经占有锁的线程。 在实际场景中,如果一个同步块(或方法)没有多个线程竞争,而且总是由同一个线程多次重入获取锁,如果每次还有阻塞线程,唤醒CPU从用户态转核心态,那么对于CPU是一种资源的浪费,为了解决这类问题,就引入了偏向锁的概念。
偏向锁的核心原理是:如果不存在线程竞争的一个线程获得了锁,那么锁就进入偏向状态,此时Mark Word的结构变为偏向锁结构, 然后线程的ID记录在锁对象的Mark Word中(使用CAS操作完成)。 以后该线程获取锁的时候判断一下线程ID和标志位,就可以直接进入同步块,连CAS操作都不需要,这样就省去了大量有关锁申请的操作,从而也就提升了程序的性能。 在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得锁,因此,在大多数情况下偏向锁是能提升性能的。
如果是同一个线程多次获得锁,如果不是偏向锁将会导致无限制的获取锁、释放锁操作,这将导致无竞争情况下的系统底层的同步操作,性能很低。如果使用偏向锁,之前获得锁的线程再次获得锁时会判断偏向锁的线程ID是否指向自己。如果指向自己,拿锁成功。如果未指向当前线程,当前线程会采用CAS操作将Mark Word中线程ID设置为当前线程ID,如果CAS操作成功,那么获取偏向锁成功,去执行同步代码块,如果CAS操作失败,那么表示有竞争,抢锁线程被挂起,撤销占锁线程的偏向锁,然后将偏向锁膨胀为轻量级锁。
偏向锁的缺点:如果锁对象时常被多条线程竞争,偏向锁就是多余的,并且其撤销的过程会带来一些性能开销。
偏向锁的撤销
假如有多个线程来竞争偏向锁,此对象锁已经有所偏向,其他的线程发现偏向锁并不是偏向自己,就说明存在了竞争,尝试撤销偏向锁(很可能引入安全点),然后膨胀到轻量级锁。
以下两个操作导致撤销偏向锁:
1)多个线程竞争偏向锁。
2)调用偏向锁对象obj.的obj.hashCode()方法计算对象的哈希码之后,偏向锁将被撤销。因为对象头的Mark Word只有64位,然而54位被线程ID占用了,因此没有存放HashCode。所以只能撤销偏向锁,将Mark Word用于存放对象的哈希码。轻量级锁会在帧栈的Lock Record(锁记录)中记录哈希码,重量级锁会在监视器中记录哈希码,起到了对哈希码备份的作用。而偏向锁没有地方备份哈希码。
偏向锁的膨胀
如果偏向锁被占据,一旦有第二个线程争抢这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到内置锁偏向状态,这时表明在这个对象锁上已经存在竞争了。 JVM检查原来持有该对象锁的占有线程是否依然存活,如果挂了,就可以将对象变为无锁状态,然后进行重新偏向,偏向为抢锁线程。如果JVM检查到原来的线程依然存活,就表明原来的线程还在使用偏执锁,发生锁竞争,撤销原来的偏向锁,将偏向锁膨胀(INFLATING)为轻量级锁。经验表明,其实大部分情况下进入一个同步代码块的线程都会是同一个线程。这也是为什么JDK会引入偏向锁出现的原因。所以,总体来说,使用偏向锁带来的好处还是大于偏向锁撤销和膨胀的所带来的代价。
全局安全点(非重点,记住结论即可)
JVM包含了一些虚拟机后台线程(包含VMThread、 GC线程、系统接收外部请求的线程等)以及用户定义线程(含线程池中的线程) 。VMThread线程的角色是一个超级线程,可以理解为JVM里面的线程母体或者所有线程的大总管。 VMThread是一个单例的对象(最原始的线程),所有其他的线程都由这个超级线程产生或触发。 VMThread线程负责完成一些基础性的VM工作,比如, VMThread线程可以协调其它线程达到全局安全点。 什么是全局安全点? JVM可以安全地进行一些全局性的操作,如GC、偏向锁解除等。在到达全局安全点后, JVM中的所有工作的用户线程都会被挂起,只有垃圾收集的native线程会持续不断地跑。也就是说,全局安全点会触发JVM的STW(Stop The World)停顿。所有的用户线程都会被暂停,没有任何响应,有点像卡死的感觉,才称为STW停顿。
有哪些场景需要让JVM进入到全局安全点呢?主要的场景如下:
- 垃圾回收。
- 偏向锁解除。
- 由于代码优化所引起的指令重排。
- JVM设置一个global safe point标志位,各用户线程主动去检查这个标志位,发现全局安全点标志位为true时,就将自己挂起。
- JVM中所有的用户线程都到达安全点之后,此时所有的用户线程都已经挂起, JVM处于STW停顿状态, JVM也达到一个全局安全点。
- 可以简单地把用户线程的安全点理解为局部安全点,而把JVM中所有的用户线程都到达局部安全点之后, JVM所处的状态称为全局安全点。
**结论:**偏向锁的撤销操作需要依赖JVM的全局安全点,从而会带来STW停顿。如果偏向锁撤销操作发生频繁,会招来频繁的STW,从而导致严重的性能问题。所以,对于高并发应用来说,一般建议关闭偏向锁。具体的方式:可以在启动命令中加上以下JVM参数:-XX:-UseBiasedLocking关闭偏向锁之后, Java内置锁默认会进入轻量级锁状态。
轻量级锁的原理
引入轻量级锁的主要目的是在多线程竞争不激烈的情况下,通过CAS机制竞争锁减少重量级锁产生的性能损耗。重量级锁使用了操作系统底层的互斥锁(Mutex Lock),会导致线程在用户态和核心态之间频繁切换,从而带来较大的性能损耗。 轻量级锁是一种自旋锁,因为JVM本身就是一个应用,所以希望在应用层面上通过自旋解决线程同步问题。
- 普通自旋锁:指当有线程来竞争锁时,抢锁线程会在原地循环等待,而不是被阻塞,直到那个占有锁的线程释放锁之后,这个抢锁线程才可以获得锁。 锁在原地循环等待的时候是会消耗CPU的, 适用于那些临界区代码耗时很短的场景,这样线程在原地等待很短的时间就能够获得锁了。 默认情况下,自旋的次数为10次 。用户可以通过-XX:PreBlockSpin选项来进行更改。
- 自适应自旋锁 :自适应自旋解决的是“锁竞争时间不确定”的问题。 总的思想是:根据上一次自旋的时间与结果调整下一次自旋的时间。 谓自适应自旋锁,就是等待线程空循环的自旋次数并非是固定的,而是会动态地根据实际情况来改变自旋等待的次数,自旋次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。轻量级锁也被称为非阻塞同步、乐观锁,因为这个过程并没有把线程阻塞挂起,而是让线程空循环等待 。自适应自旋锁的大概原理是:
- 如果抢锁线程在同一个锁对象上之前成功获得过锁,那么JVM就会认为这次自旋也很有可能再次成功,因此允许自旋等待持续相对更长的时间。
- 如果对于某个锁,抢锁线程在很少成功获得过,那么JVM将可能减少自旋时间甚至省略自旋过程,以避免浪费处理器资源。
轻量级锁的膨胀
轻量级锁的问题在哪里呢?虽然大部分临界区代码的执行时间都是很短的, 但是也会存在执行得很慢的临界区代码。临界区代码执行耗时较长,在其执行期间其他线程都在原地自旋等待,会空消耗CPU。因此,如果竞争这个同步锁的线程很多,就会有多个线程在原地等待继续空循环消耗CPU(空自旋),这会带来很大的性能损耗。 轻量级锁本意是为了减少多线程进入操作系统底层的互斥锁( Mutex Lock)的概率。所以,在争用激烈的场景下,轻量级锁会膨胀为基于操作系统内核互斥锁实现的重量级锁。
重量级锁原理
在JVM中,每个对象都关联一个监视器,这里的对象包含了Object实例和Class实例。监视器是一个同步工具,相当于一个许可证,拿到许可证的线程即可以进入临界区进行操作,没有拿到则需要阻塞等待。重量级锁通过监视器的方式保障了任何时间只允许一个线程通过受到监视器保护的临界区代码。 在Hotspot虚拟机中, 监视器是由C++类ObjectMonitor 实现的。竞争访问临界区时,所有请求锁的线程首先被放在这个竞争队列中。被阻塞的队列放入阻塞队列中。线程的阻塞或者唤醒都需要操作系统来帮忙,进程需要从用户态切换到内核态。 用户态是应用程序运行的空间,为了能访问到内核管理的资源(例如CPU、内存、 I/O),可以通过内核态所提供的访问接口实现,这些接口就叫系统调用。 由于JVM轻量级锁使用CAS进行自旋抢锁,这些CAS操作都处于用户态下,进程不存在用户态和内核态之间的运行切换,因此JVM轻量级锁开销较小。而JVM重量级锁使用了Linux内核态下的互斥锁(Mutex),这是重量级锁开销很大的原因。
synchronized的执行过程
-
抢锁时先判断是否是偏向锁,如果是再判断Mark Word中线程ID是否是抢锁线程ID,如果是抢锁成功,开始执行临界区代码。
-
如果如果Mark Word中线程ID并未指向抢锁线程,就通过CAS操作竞争锁。 如果竞争成功,就将Mark Word中线程ID设置为抢锁线程。如果CAS操作竞争失败,就说明发生了竞争,撤销偏向锁,进而升级为轻量级锁。
-
JVM使用CAS将锁对象的Mark Word替换为抢锁线程的锁记录指针,如果成功,抢锁线程就获得锁。如果替换失败,就表示其他线程竞争锁, JVM尝试使用CAS自旋替换抢锁线程的锁记录指针,如果自旋成功(抢锁成功),那么锁对象依然处于轻量级锁状态。如果JVM的CAS替换锁记录指针自旋失败,轻量级锁膨胀为重量级锁,后面等待锁的线程也要进入阻塞状态。
总体来说,偏向锁是在没有发生锁争用的情况下使用;一旦有了第二个线程的争用锁,偏向锁就会升级为轻量级锁;如果锁争用很激烈,轻量级锁的CAS自旋到达阈值后(超过最大自旋数量),轻量级锁就会升级为重量级锁。
线程间的通信
线程是操作系统调度的最小单位,如果每个线程间都孤立地运行,就会造资源浪费。线程的通信可以被定义为:当多个线程共同操作共享的资源时,线程间通过某种方式互相告知自己的状态,以避免无效的资源争夺。 “等待-通知”通信方式是Java中使用普遍的线程间通信方式,其经典的案例就是“生产者-消费者”模式。 Java语言中“等待-通知”方式的线程间的通信使用对象的wait()、 notify()两类方法来实现。每个Java对象都有wait()、 notify()两类实例方法,属于Object对象的方法。
-
wait()方法,对象的wait()方法的主要作用是让当前线程阻塞并等待被唤醒。使用wait()方法时也一定需要放在同步块中,线程进入阻塞队列等待唤醒, wait(long timeout) 限时等待版本,指定的时间timeout用完,线程不再等待被唤醒。
-
notify()方法 ,唤醒在等待的线程。使用notify()方法时也需要放在同步块中。 notify()方法的主要作用如下: locko.notify()调用后,唤醒locko监视器等待集中的第一个等待线程;被唤醒的线程进入EntryList,其状态从WAITING等待状态变成BLOCKED。locko.notifyAll()被调用后,唤醒locko监视器等待集中的全部等待线程;所有被唤醒的线程进入EntryList,线程状态从WAITING等待状态变成BLOCKED。EntryList中的线程抢夺到监视器Owner权利之后,线程的状态从BLOCKED变成Runnable,具备重新执行的资格。
-
需要在 synchronized 同步块的内部使用 wait 和 notify,在调用同步对象的wait()和notify()系列方法时,“当前线程”必须拥有该对象的同步锁,也就是说, wait()和notify()系列方法需要在同步块中使用,否则JVM会抛出非法监视器状态异常。
-
使用wait()方法时使用while进行条件判断:如果是在某种条件下进行等待,对条件的判断不能使用if语句做一次性判断,而是使用while循环进行反复判断。只有这样才能在线程被唤醒后继续检查wait的条件,并在条件没有满足的情况下继续等待。
public T fetch() throws Exception { while (amount <= 0){ // 不能改成 if ,如果if被唤醒之后不会再次判断amount synchronized (NOT_EMPTY) { Print.tcfo("队列已经空了! "); //等待未空通知 NOT_EMPTY.wait(); } } //省略其他 }