一、Synchronized使用

  1. - synchronized关键字提供一种锁的机制,确保共享变量的互斥访问,保证临界区操作原子性。
  2. - synchronized关键字包括monitor enter monitor exit 两个JVM命令。(字节码层面,jvm会添加两个monitor exit 命令,jvm考虑到异常退出情况)

保证任何线程在执行monitor enter成功前(成功前?后?),从主内存中获取数据,而不是缓存,在monitor exit执行成功后,共享变量的更新的值,刷新到主内存中

  1. - synchronized的指令严格遵守java happens-before原则

主内存与工作内存

所有的线程共享的,主要包括本地方法区
所有的变量都存储在主内存中(虚拟机内存的一部分),对于所有线程都是共享的。
每个线程都有自己的工作内存,保存主内存中变量的副本
线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量
线程之间无法直接访问对方的工作内存中的变量,线程间变量的传递通过主内存来完成,即:线程、主内存、工作内存

对象锁

synchronized(this) 对象锁,
阻塞其他线程调用同一对象的代码块。this monitor
如果对象A两个方法1,2都被synchronized修饰,将A传递给两个线程,则Thread1执行完方法1,Thread2才能执行方法2.

类锁

synchronized(Object.class) 类锁,
阻塞其他线程调用同一个类实例的代码块。class monitor
如果类A的两个静态方法1,2都被synchrogazed修饰,实例化A两个对象,分别传递给两个线程,
则Thread1执行完方法1,Thread2才能执行方法2.

实例方法,锁的是调用该方法的实例(即,方法体执行期间的 this)相关联的管程。
静态方法,锁的是定义该方法的类所对应的Class 对象。一旦方法体执行结束,不管是正常还是异常结束,都会在之前执行lock 动作的那个管程上自动执行一个 unlock 动作

当A线程等待B线程synchrogazed锁释放,而阻塞时,A线程无法使用interrupt方法进行中断,即Synchrogazed进行锁控制无法中断。
image.png

二、底层原理

深入Synchronized关键字

  1. 1. SynchronizedJVM内置锁,基于**Monitor**机制实现,依赖操作系统的互斥原语Mutex(互斥量),是一个重量级锁,性能较低。每一个对象都与monitor关联,一个monitorlock的锁,只能被一个线程在同一时刻获取。

如果monitor计数为0,则表示monitor的lock没有被获取,某个线程获取后,计数+1,同一线程重入,计数再次+1,反之,释放则-1,直到计数为零,其他线程可获取。
1.6版本(含)之后进行重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、自适应自旋(Adaptive Spinning)等技术来减少锁操作的开销,并发性能已经基本与Lock持平.

  1. 2. 虚拟机通过一个同步结构(monitor)支持方法和方法中的指令序列同步

同步方法
通过方法中的access_flags中设置ACC_SYNCHRONIZED标志来实现;
同步代码块
通过monitorenter和monitorexit来实现。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。
image.png
image.png
同步代码块
image.png

Monitor(管程/监视器)

Monitor,直译为“监视器”,操作系统领域为“管程”。管程是指管理共享变量以及对共享变量操作的过程,让它们支持并发。在Java 1.5之前,Java语言提供的唯一并发语言就是管程,Java 1.5之后提供的SDK并发包也是以管程为基础的。Synchronized关键字和wait()、notify()、notifyAll()这三个方法是Java中实现管程技术的组成部分。

MESA模型

image.png
管程中引入了条件变量的概念,而且每个条件变量都对应有一个等待队列。条件变量和等待队列的作用是解决线程之间的同步问题。

wait()正确使用

while(!不满足唤醒条件) {
wait();
}
唤醒的时间和获取到锁继续执行的时间是不一致的,刚被被唤醒的线程可能再次不满足条件,所以循环检验条件。
MESA模型的wait()方法还有一个超时参数,为了避免线程进入等待队列永久阻塞。

notify()和notifyAll()分别何时使用

满足以下三个条件时,可以使用notify(),其余情况尽量使用notifyAll():

  1. 1. 所有等待线程拥有相同的等待条件;
  2. 1. 所有等待线程被唤醒后,执行相同的操作;
  3. 1. 只需要唤醒一个线程。

java内置管程synchronized

Java 参考了 MESA 模型,语言内置的管程(synchronized)对 MESA 模型进行了精简。MESA 模型中,条件变量可以有多个,Java 语言内置的管程里只有一个条件变量
模型如下图所示
image.pngimage.png

获取锁时,是将当前线程插入到cxq的头部。
释放锁时,默认策略(QMode=0)是:如果EntryList为空,则将cxq中的元素按原有顺序插入到EntryList,并唤醒第一个线程,也就是当EntryList为空时,是后来的线程先获取锁。_EntryList不为空,直接从_EntryList中唤醒线程。

对象如何记录锁状态

对象的内存布局

image.png
image.png

Hotspot虚拟机中,对象在内存中存储的布局可以分为三块区域

  1. - 对象头(Header
  2. - Mark Word

用于存储对象自身的运行时数据,这部分数据的长度在32位和64位的虚拟机中分别为32bit(4字节)和64bit(8字节)。
如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,

  1. - Klass Pointer

klass类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定当前对象是哪个类的实例。 与Class注意区分。Klass是jvm访问类元信息的指针。Class是程序员访问类元信息的渠道
32位4字节,64位开启指针压缩或最大堆内存<32g时4字节,否则8字节。jdk1.8默认开启指针压缩后为4字节,当在JVM参数中关闭指针压缩(-XX:-UseCompressedOops)后,长度为8字节。

  1. - 数组长度(只有数组对象有)

数组对象, 在对象头中必须有一块数据用于记录数组长度。 4字节

  1. - 实例数据(Instance Data

存放类的属性数据信息,包括父类的属性信息;

  1. - 对齐填充(Padding

由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

问题:new Object(),占用多少个字节。
64位操作系统
对象头
markword:8字节
MetaData(即Klass Pointer):原始8字节,(默认)开启压缩后,4字节。
实例数据:0,
目前8+4=12不是8的整数倍,需要对其填充。故总的为16字节。
32位操作系统
对象头
markword:4字节
MetaData:原始8字节,(默认)开启压缩后,4字节。共8字节

对象布局验证

  1. public static void main(String[] args) {
  2. Object obj = new Object();
  3. //查看对象内部信息
  4. System.out.println(ClassLayout.parseInstance(obj).toPrintable());
  5. int[] arr = new int[4];
  6. System.out.println(ClassLayout.parseInstance(arr).toPrintable());
  7. }
  1. java.lang.Object object internals:
  2. OFFSET SIZE TYPE DESCRIPTION VALUE
  3. 0 4 (object header) mark word 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
  4. 4 4 (object header) mark word 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
  5. 8 4 (object header) klass point e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
  6. 12 4 (loss due to the next object alignment) 填充数据
  7. Instance size: 16 bytes
  8. Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
  9. 由上面打印结果可知
  10. Object对象:总大小16字节。Instance size: 16 bytes
  11. 对象头长度12字节【mark word + klass point】, 填充数据4字节
  12. [I object internals:
  13. OFFSET SIZE TYPE DESCRIPTION VALUE
  14. 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
  15. 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
  16. 8 4 (object header) 6d 01 00 f8 (01101101 00000001 00000000 11111000) (-134217363)
  17. 12 4 (object header) 04 00 00 00 (00000100 00000000 00000000 00000000) (4)
  18. 16 16 int [I.<elements> N/A
  19. Instance size: 32 bytes
  20. Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
  21. 由上面打印结果可知
  22. Object对象:总大小32字节。Instance size: 32 bytes
  23. 对象头长度16字节【mark word + metaDate point + 数组长度】
  24. 数组所占空间 长度4*4字节(int所占空间)

Mark Word记录锁状态

锁状态被记录在每个对象的对象头的Mark Word中。
Hotspot通过markOop类型实现Mark Word,具体实现位于markOop.hpp文件中。由于对象需要存储的运行时数据很多,考虑到虚拟机的内存使用,markOop被设计成一个非固定的数据结构,以便在极小的空间存储尽量多的数据,根据对象的状态复用自己的存储空间。
简单点理解就是:MarkWord 结构搞得这么复杂,是因为需要节省内存,让同一个内存区域在不同阶段有不同的用处。

Mark Word的结构

64位JVM下的对象结构
image.png

  1. - hashcode 保存对象的哈希码。运行期间调用System.identityHashCode()来计算,延迟计算,并把结果赋值到这里。
  2. - age 保存对象的分代年龄。表示对象被GC的次数,当该次数到达阈值的时候,对象就会转移到老年代。
  3. - biased_lock 偏向锁标识位。由于无锁和偏向锁的锁标识都是 01,没办法区分,这里引入一位的偏向锁标识位。
  4. - lock 锁状态标识位。区分锁状态,比如11时表示对象待GC回收状态, 只有最后2位锁标识(11)有效。
  5. - JavaThread*: 保存持有偏向锁的线程ID。偏向模式的时候,当某个线程持有对象的时候,这里就会被置为该线程的ID 在后面的操作中,就无需再进行尝试获取锁的动作。这个线程ID**并不是JVM分配的线程ID号**,和Java Thread中的ID是两个概念。
  6. - epoch 保存偏向时间戳。偏向锁在CAS锁操作过程中,偏向性标识,表示对象更偏向哪个锁。
  7. - ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。当锁获取是无竞争时,JVM使用原子操作而不是操作系统互斥量,这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM通过CAS操作在对象的Mark Word中设置指向锁记录的指针。
  8. - ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。

如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到Monitor以管理等待的线程。在重量级锁定的情况下,JVM在对象的ptr_to_heavyweight_monitor设置指向Monitor的指针

不同锁mark word状态变化

image.png

Synchronized膨胀过程

无锁(可偏向)—>偏向锁—>轻量级锁(自旋)—>重量级锁
jvm,在无锁状态时,锁标识是偏向锁标识,但是没有记录线程ID,对象头仍然记录对象的hashCode,在升级成偏向锁时,记录获取锁的线程ID。

偏向锁

偏向锁是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了消除数据在无竞争情况下锁重入(CAS操作)的开销而引入偏向锁。对于没有锁竞争的场合,偏向锁有很好的优化效果。
当JVM启用了偏向锁模式(jdk6默认开启),新创建对象的Mark Word中的Thread Id为0,说明此时处于可偏向但未偏向任何线程,也叫做匿名偏向状态(anonymously biased)。

偏向锁延迟偏向

偏向锁模式存在偏向锁延迟机制:HotSpot 虚拟机在启动后有个4s 的延迟才会对每个新建的对象开启偏向锁模式。
JVM启动时会进行一系列的复杂活动,比如装载配置,系统类初始化等等。在这个过程中会使用大量synchronized关键字对对象加锁,且这些锁大多数都不是偏向锁。为了减少初始化时间,JVM默认延时加载偏向锁。
//关闭延迟开启偏向锁 -XX:BiasedLockingStartupDelay=0
//禁止偏向锁 -XX:-UseBiasedLocking //启用偏向锁

轻量级锁

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段
此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间多个线程访问同一把锁的场合,就会导致轻量级锁膨胀为重量级锁。

轻量级锁情况下,对象的markword存储在栈空间中,重入时,在栈中保存null(因为已经保存Markword);
释放锁时,从栈 空间中还原Markword,变为无锁对象。

易混淆
轻量级锁期间不存在自旋。自旋发生在重量级锁竞争期间(使用自旋避免线程阻塞,减少性能损耗)。

锁对象状态转换

24584.png

synchronized锁优化

偏向锁批量重偏向&批量撤销

从偏向锁的加锁解锁过程中可看出,当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point时,再将偏向锁撤销为无锁状态或升级为轻量级,会消耗一定的性能,所以在多线程竞争频繁的情况下,偏向锁不仅不能提高性能,还会导致性能下降。于是,就有了批量重偏向与批量撤销的机制。

原理

以class为单位,为每个class维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。
每个class对象会有一个对应的epoch字段,每个处于偏向锁状态对象的Mark Word中也有该字段,其初始值为创建该对象时class中的epoch的值。每次发生批量重偏向时,就将该值+1,同时遍历JVM中所有线程的栈,找到该class所有正处于加锁状态的偏向锁,将其epoch字段改为新值。下次获得锁时,发现当前对象的epoch值和class的epoch不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过CAS操作将其Mark Word的Thread Id 改成当前线程Id。
当达到重偏向阈值(默认20)后,假设该class计数器继续增长,当其达到批量撤销的阈值后(默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向,之后,对于该class的锁,直接走轻量级锁的逻辑。

应用场景

批量重偏向(bulk rebias)机制是为了解决:一个线程创建了大量对象并执行了初始的同步操作,后来另一个线程也来将这些对象作为锁对象进行操作,这样会导致大量的偏向锁撤销操作。
批量撤销(bulk revoke)机制是为了解决:在明显多线程竞争剧烈的场景下使用偏向锁是不合适的。

总结

  1. 1. 批量重偏向和批量撤销是针对类的优化,和对象无关。
  2. 1. 偏向锁重偏向一次之后不可再次重偏向。
  3. 1. 当某个类已经触发批量撤销机制后,JVM会默认当前类产生了严重的问题,剥夺了该类的新实例对象使用偏向锁的权利

自旋优化

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。

  1. - 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
  2. - Java 6 之后自旋是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,比较智能。
  3. - Java 7 之后不能控制是否开启自旋功能

注意:自旋的目的是为了减少线程挂起的次数,尽量避免直接挂起线程(挂起操作涉及系统调用,存在用户态和内核态切换,这才是重量级锁最大的开销) 。

锁粗化

假设一系列的连续操作都会对同一个对象反复加锁及解锁,甚至加锁操作是出现在循环体中的,即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
如果JVM检测到有一连串零碎的操作都是对同一对象的加锁,将会扩大加锁同步的范围(即锁粗化)到整个操作序列的外部。
例如: StringBuffer buffer = new StringBuffer();
append 方法都需要加锁和解锁,如果JVM检测到有一连串的对同一个对象加锁和解锁的操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。

锁消除

锁消除即删除不必要的加锁操作。锁消除是Java虚拟机在JIT编译期间,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。
例如:StringBuffer buffer = new StringBuffer();作为一个局部变量。每次append都会加锁,但是作为一个局部变量,当前线程独有,不会存在并发问题。

逃逸分析(Escape Analysis)

逃逸分析,是一种可以有效减少Java 程序中同步负载内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将对象分配到堆上。逃逸分析的基本行为就是分析对象动态作用域。

方法逃逸(对象逃出当前方法)

当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。

线程逃逸((对象逃出当前线程)

对象可能被其它线程访问到,例如赋值给类变量或可以在其它线程中访问的实例变量。
使用逃逸分析,编译器可以对代码做如下优化:
1.同步省略或锁消除(Synchronization Elimination)。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步
2.将堆分配转化为栈分配(Stack Allocation)。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
3.分离对象或标量替换(Scalar Replacement)。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。