🍇一、synchronized
1. 介绍
synchronize
是Java
中的关键字,可以用在实例方法、静态方法、同步代码块。synchronize
解决了:原子性、可见性、有序性三个问题,用来保证多线程环境下共享变量的正确性。
🥇原子性:执行被synchronized
修饰的方法和代码块,都必须要先获得类或者对象锁,执行完之后再释放锁,中间是不会中断的,这样就保证了原子性。
🥈可见性:执行被synchronized
修饰的方法和代码块,一个线程获得了锁,执行完毕之后, 在释放锁之前,会对变量的修改同步回内存中,对其它线程是可见的。
🥉有序性:synchronized
保证了每个时刻都只有一个线程访问同步代码块或者同步方法,这样就相当于是有序的。
2. 使用示例
用一个示例来展示synchronized
的用法,现在有两个线程,对一个变量进行自增10000000次操作,在正确的情况下,最后的结果应该是20000000。但是实际使用过程中可能会出现各种情况。
public class MainDemo extends Thread {
private static int increment = 0;
@Override
public void run () {
for (int i = 0; i < 10000000; i++) {
increment++;
}
}
public static void main(String[] args) throws InterruptedException {
MainDemo mainDemo = new MainDemo();
Thread t1 = new Thread(mainDemo);
Thread t2 = new Thread(mainDemo);
t1.start();
t2.start();
// 阻塞主线程,直到t1和t2运行完毕
t1.join();
t2.join();
System.out.println(increment);
}
}
2.1. 修饰普通方法
synchronized
修饰普通方法只需要在方法上加上synchronized
即可。synchronized
修饰的方法,如果子类重写了这个方法,子类也必须加上synchronized
关键字才能达到线程同步的效果。
public class MainDemo extends Thread {
private static int increment = 0;
@Override
public void run () {
for (int i = 0; i < 10000000; i++) {
incrementMethod();
}
}
public synchronized void incrementMethod() {
increment++;
}
public static void main(String[] args) throws InterruptedException {
MainDemo mainDemo = new MainDemo();
Thread t1 = new Thread(mainDemo);
Thread t2 = new Thread(mainDemo);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(increment);
}
}
2.2. 修饰静态方法
当synchronized
作用于静态方法时,和实例方法类似,只需要在静态方法上面加上synchronized
关键字即可。
public class MainDemo extends Thread {
private static int increment = 0;
@Override
public void run () {
for (int i = 0; i < 10000000; i++) {
incrementMethod();
}
}
public static synchronized void incrementMethod() {
increment++;
}
public static void main(String[] args) throws InterruptedException {
MainDemo mainDemo = new MainDemo();
Thread t1 = new Thread(mainDemo);
Thread t2 = new Thread(mainDemo);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(increment);
}
}
2.3. 修饰同步代码块
修饰同步代码块可以使用:类对象和实例对象,但是要保证唯一性,多个线程使用的对象要是同一个对象。唯一性的意思就是说下面的objectLock
必须是同一个对象,如果每个线程都新建一个对象,那么就达不到保证线程安全的效果。
public class MainDemo extends Thread {
private static int increment = 0;
private static Object objectLock = new Object();
@Override
public void run () {
for (int i = 0; i < 10000000; i++) {
synchronized (objectLock) {
increment++;
}
}
}
public static void main(String[] args) throws InterruptedException {
MainDemo mainDemo = new MainDemo();
Thread t1 = new Thread(mainDemo);
Thread t2 = new Thread(mainDemo);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(increment);
}
}
3. 虚拟机标记
synchronized
可以保证线程安全,那么虚拟机是如何识别synchronized
的呢?
在Java
虚拟机层面标记synchronized
修饰的代码有两种方式:
🥇ACC_SYNCHRONIZED
标识位
🥈monitorenter
和monitorexit
指令
3.1 同步代码块
当synchronized
修饰同步代码块的时候。
public class MainDemo extends Thread {
private static int increment = 0;
private static Object objectLock = new Object();
@Override
public void run () {
for (int i = 0; i < 10000000; i++) {
synchronized (objectLock) {
increment++;
}
}
}
public static void main(String[] args) throws InterruptedException {
MainDemo mainDemo = new MainDemo();
Thread t1 = new Thread(mainDemo);
Thread t2 = new Thread(mainDemo);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(increment);
}
}
我们先编译这个类,然后再反编译反编译。
// 编译
javac MainDemo.java
// 反编译
javap -v MainDemo.class
反编译之后输出如下内容,我们可以看到在13行和23行有monitorenter
和monitorexit
两个指令,其中monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置,当执行monitorenter
指令的时候需要去获取对象锁,执行monitorexit
的时候释放对象锁。
public void run();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: iconst_0
1: istore_1
2: iload_1
3: ldc #2 // int 10000000
5: if_icmpge 38
8: getstatic #3 // Field objectLock:Ljava/lang/Object;
11: dup
12: astore_2
13: monitorenter
14: getstatic #4 // Field increment:I
17: iconst_1
18: iadd
19: putstatic #4 // Field increment:I
22: aload_2
23: monitorexit
24: goto 32
27: astore_3
28: aload_2
29: monitorexit
30: aload_3
31: athrow
32: iinc 1, 1
35: goto 2
38: return
3.2. 同步方法
当synchronized
修饰实例方法或者静态方法的时候。
public class MainDemo extends Thread {
private static int increment = 0;
@Override
public void run () {
for (int i = 0; i < 10000000; i++) {
incrementMethod();
}
}
public synchronized void incrementMethod() {
increment++;
}
public static void main(String[] args) throws InterruptedException {
MainDemo mainDemo = new MainDemo();
Thread t1 = new Thread(mainDemo);
Thread t2 = new Thread(mainDemo);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(increment);
}
}
我们先编译这个类,然后再反编译反编译。
// 编译
javac MainDemo.java
// 反编译
javap -v MainDemo.class
无论是实例方法还是静态方法,实现都是通过ACC_SYNCHRONIZED
标识,反编译之后可以看到在方法上有ACC_SYNCHRONIZED
标识,表明这是一个同步方法,线程执行这个方法的时候都会先去获取对象锁,方法执行完毕之后会释放对象锁。
public synchronized void incrementMethod();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #4 // Field increment:I
3: iconst_1
4: iadd
5: putstatic #4 // Field increment:I
8: return
LineNumberTable:
line 13: 0
line 14: 8
🍉二、底层实现
1. 对象结构
实例对象在内存中的结构分了三个部分:对象头、实例数据、对齐填充。对象头又包含了:标记字段Mark Word
、类型指针KlassPointer
、长度Length field
三个部分。
-
对象头:存储了锁状态标志、线程持有的锁等标志。
- 标记字段
Mark Word
:用于存储对象自身的运行时数据,他是经过轻量级锁和偏向锁的关键 - 类型指针
KlassPointer
:是对象指向他的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例 - 长度
Length field
:如果是数组对象,对象头还包含了数组长度。
- 标记字段
-
实例数据:对象真正存储的有效信息,存放类的属性数据信息。
-
对齐填充:对齐填充不是必须存在的,仅仅时让对象的长度达到8字节的整数倍,其中一个原因就是为了不让对象数据跨缓存行,用空间换时间。
2. Mark Word结构
Mark word
主要用于存储对象在运行时自身的一些数据,比如GC
信息、锁信息等。在64位虚拟机中Mark Word
的结构如下:
3. 查看对象头结构数据
3.1. 引入相关的jar包
首先导入相关的包
org.openjdk.jol
jol-core
0.9
3.2. 示例代码
public class SynDemo {
public static void main(String[] args) {
Object object = new Object();
System.out.println(Integer.toString(object.hashCode(), 2));
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
}
3.3. 对象头数据
hashcode: 10111101001111100111011000010
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 c2 ce a7 (00000001 11000010 11001110 10100111)
4 4 (object header) 17 00 00 00 (00010111 00000000 00000000 00000000)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
3.4. Mark Word分析
从对象头的结构可以得知,Mark word
的大小为8个字节,那么我们输出的对象头数据的前两行就是`Mark word
的内容,其中value
就是Mark word
的数据。同时我们也输出了对象头的hashcode
值,用于对比。
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 c2 ce a7 (00000001 11000010 11001110 10100111)
4 4 (object header) 17 00 00 00 (00010111 00000000 00000000 00000000)
Mark word
值分了两种格式输出,前面是十六进制的,后面是二进制的,我们输出的hashcode
值是:10111101001111100111011000010
,对象头的hashcode
是31位的,补全之后的hashcode
值:0010111101001111100111011000010
从二进制中好像是找不到对应的数据,下面做一个处理。
如下图我们将上面的二进制按照倒叙排列,图中红色方框内的数据就和hashcode
值完全对应上了,再结合对象头Mark word
结构,最后两位绿框就是锁的标识位。
4. 如何实现
synchronized
是借助Java
对象头来实现的,通过对象头的介绍,可以知道,对象头的Mark word
里的数据是在变化的,不同的数据表示了不同类型的锁,而synchronized
就是通过获取这些锁来实现线程安全的。
前面我们说了当我们使用synchronized来保证线程安全的时候,虚拟机在编译代码的时候,会添加标记:ACC_SYNCHRONIZED
和monitorenter
、monitorexit
指令。
当虚拟机执行代码的时候,如果发现了这些标记,那么就会让线程去获取对象锁,也就是去修改对象头的数据,只有获取到锁的线程才能继续执行代码,其它的线程则需要等待,直到获取锁的线程释放锁。
🍏三、锁升级过程
在1.6之前,synchronized
只有重量级锁,在1.6版本对synchronized
锁进行了优化,有了偏向锁,轻量级锁。
由此锁升级有四种状态:无锁,偏向锁,轻量级锁,重量级锁。锁升级是不可逆的,只能升级不能降级。
锁的升级是通过对象头的Mark word
的数据变化来完成的,数据会根据锁变化而变化。
1. 无锁
1.1 Mark Word结构
无锁就是没有线程来抢站对象头这个时候的Mark word
的数据如下:
锁状态 | 25bit | 31bit | 1bit | 4bit | 1bit 是否偏向锁 | 2bit 锁标识位 |
---|---|---|---|---|---|---|
无锁 | unused | 对象的hashcode | unused | 分代年龄 | 0 | 01 |
1.2. 对象头结构数据
public class SynDemo {
public static void main(String[] args) {
Object object = new Object();
System.out.println(Integer.toString(object.hashCode(), 2));
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
}
可以看到无锁的时候对象头最后八位的数据是00000001,标识锁的两位是01。
hashcode: 10111101001111100111011000010
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 c2 ce a7 (00000001 11000010 11001110 10100111)
4 4 (object header) 17 00 00 00 (00010111 00000000 00000000 00000000)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
2. 偏向锁
2.1 Mark Word结构
偏向锁的Mark word
锁标志位和无锁一样是01,是否偏向锁是1
锁状态 | 54bit | 2bit | 4bit | 1bit 是否偏向锁 | 2bit 锁标识位 |
---|---|---|---|---|---|
无锁 | unused | 对象的hashcode | 分代年龄 | 1 | 01 |
2.2 什么是偏向锁
偏向锁主要是来优化同一个线程多次获取同一个锁的,有时候线程t
在执行同步代码的时候先去获取锁,执行完了之后不会释放锁,然后第二次线程t
第二次执行同步代码的时候先去获取锁,发现Mark word
的线程ID
就是它,就不需要重新加锁。
在JDK1.6之后是默认开启偏向锁的,但是我们在使用的时候是绕过偏向锁了直接进入轻量级锁,这是因为虽然默认开启了偏向锁,但是开启是有延迟的,大概是4s钟,也即是程序刚启动创建的对象是不会开启偏向锁的,4秒之后创建的对象才会开启,可以通过JVM
参数来设置延迟时间。
//关闭延迟开启偏向锁
-XX:BiasedLockingStartupDelay=0
//禁止偏向锁
-XX:-UseBiasedLocking
//启用偏向锁
-XX:+UseBiasedLocking
在JDK15中偏向锁已经被标记为Deprecate
2.3 加锁过程
线程获取偏向锁的过程,当线程执行被synchronized
修饰的同步代码块的时候
1.检查锁标志位是否是01
2.检查是否是偏向锁
3.如果不是偏向锁,直接通过CAS
替换Mark word
的线程ID
为当前线程ID
,并修改是否偏向锁为1
4.如果是偏向锁,检查Mark word
的线程ID
是否是当前线程的ID
,如果是的话直接就执行同步代码块,如果不是当前线程的线程ID
也是通过CAS
替换Mark word
的线程ID
为当前线程ID
5.CAS
成功之后便执行同步代码
2.4 锁升级过程
上面我们说在加锁的过程中都是通过CAS
操作替换Mark word
的线程ID
为当前线程的ID
,如果CAS
失败了就可能会升级为轻量级锁
1.当CAS
失败的时候,原持有偏向锁到达线程安全点的时候
2.检查原持有偏向锁的线程的线程状态
3.如果原持有偏向锁的线程还没有退出同步代码块就升级为轻量级锁,并且仍然由原线程持有轻量级锁
4.如果原持有偏向锁的线程已经退出同步代码块了,偏向锁撤销Mark word
的线程ID
更新为空,是否偏向改为0
5.如果原线程不竞争锁,则偏向锁偏向后来的线程,如果原线程要竞争锁,则升级为轻量级锁
如果调用了对象的hashcode方法或者执行了wait和 notify方法,锁升级为重量级锁。
2.2 对象头结构数据
设置睡眠时间为5秒,这样才会进入偏向锁,只有一个线程来竞争锁,所以会转向偏向锁
public class SynDemo {
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
SynDemo synDemo = new SynDemo();
synchronized (synDemo) {
System.out.println(ClassLayout.parseInstance(synDemo).toPrintable());
}
}
}
这里只有一个线程在竞争锁,所以锁就是偏向锁,锁标志位是01,是否偏向是1。
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 88 80 54 (00000101 10001000 10000000 01010100)
4 4 (object header) c2 7f 00 00 (11000010 01111111 00000000 00000000)
8 4 (object header) 05 c1 00 f8 (00000101 11000001 00000000 11111000)
3. 轻量级锁
3.1 Mark Word结构
锁状态 | 62bit | 2bit |
---|---|---|
轻量级锁 | 指向线程栈中的Lock Record指针 | 00 |
3.2什么是轻量级锁
当偏向锁,被另外的线程访问的时候,偏向锁就会升级为轻量级锁,其它线程会通过自旋的形式尝试获取锁,不会阻塞线程,从而提高了性能。
3.3 加锁过程
1.在当前线程的栈帧中建立一个名为锁记录Lock Record
空间
2.拷贝对象头的Mark word
到锁记录空间,这个拷贝的过程官方称为Displaced Mark Word
3.使用CAS
操作把Mark Word
中的指针指向线程的锁记录空间,更新锁标志位为00
4.当线程持有偏向锁并且偏向锁升级为轻量级锁,
5.如果线程是持有偏向锁升级为轻量级锁那么不用通过CAS
获取锁,而是直接持有锁
3.4 释放锁
当线程执行完同步代码块的时候,就会释放锁
1.用CAS
操作把对象当前的Mark Word
和线程中复制的Displaced Mark Word
替换回来
2.如果替换成功,同步过程就完成了
3.如果替换不成功,说明有其它线程尝试获取该锁,就要在释放锁的同时,唤醒被挂起的线程
3.5 锁升级过程
当线程通过CAS
获取轻量级锁,如果CAS
的次数过多,没有获取到轻量级锁,那么锁就会升级为重量级锁。
除此之外一个线程在持有锁,一个在自旋,又有第三个线程来竞争锁,轻量级锁升级为重量级锁。
3.6 对象头结构数据
public class SynDemo {
public static void main(String[] args) throws InterruptedException {
SynDemo synDemo = new SynDemo();
Thread t1 = new Thread(() -> {
synchronized (synDemo) {
System.out.println(ClassLayout.parseInstance(synDemo).toPrintable());
}
});
t1.start();
}
}
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 08 49 20 11 (00001000 01001001 00100000 00010001)
4 4 (object header) 00 70 00 00 (00000000 01110000 00000000 00000000)
8 4 (object header) 05 c1 00 f8 (00000101 11000001 00000000 11111000)
4. 重量级锁
4.1 Mark word结构
锁状态 | 62bit | 2bit |
---|---|---|
轻量级锁 | 指向互斥量的指针 | 10 |
4.2 什么是重量级锁
在Java1.6之前synchronized
的实现只能通过重量级锁实现,在1.6之后当轻量级锁自旋一定次数后还是没有获取到锁,此时锁就会升级为重量级锁。
重量级锁在竞争锁的时候,除了持有锁的线程,其它竞争锁的线程都会在等待队列中,防止不必要的开销。
4.3 对象头数据
public class SynDemo {
public static void main(String[] args) throws InterruptedException {
SynDemo synDemo = new SynDemo();
Thread t1 = new Thread(() -> {
synchronized (synDemo){
System.out.println(ClassLayout.parseInstance(synDemo).toPrintable());
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread t2 = new Thread(() -> {
synchronized (synDemo){
System.out.println(ClassLayout.parseInstance(synDemo).toPrintable());
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.start();
t2.start();
}
}
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 3a f2 6f 1c (00111010 11110010 01101111 00011100)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000)
8 4 (object header) 05 c1 00 f8 (00000101 11000001 00000000 11111000)
4.3 重量级锁实现原理
重量级锁是借助Monitor
来实现的,在Java
虚拟机中Monitor
机制是基于C++实现的,每一个Monitor
都有一个ObjectMonitor
对象。当锁升级为重量级锁的时候Mark word
中的指针就指向ObjectMonitor
对象地址。通过ObjectMonitor
就可以实现互斥访问同步代码。
ObjectMonitor
的部分变量,用于存储锁竞争过程中的一些值。
ObjectMonitor() {
// 处于wait状态的线程,会被加入到_WaitSet
ObjectWaiter * volatile _WaitSet;
//处于等待锁block状态的线程,会被加入到该列表
ObjectWaiter * volatile _EntryList;
// 指向持有ObjectMonitor对象的线程
void* volatile _owner;
// _header是一个markOop类型,markOop就是对象头中的Mark Word
volatile markOop _header;
// 抢占该锁的线程数,约等于WaitSet.size + EntryList.size
volatile intptr_t _count;
// 等待线程数
volatile intptr_t _waiters;
// 锁的重入次数
volatile intptr_ _recursions;
// 监视器锁寄生的对象,锁是寄托存储于对象中
void* volatile _object;
// 操作WaitSet链表的锁
volatile int _WaitSetLock;
// 嵌套加锁次数,最外层锁的_recursions属性为0
volatile intptr_t _recursions;
// 多线程竞争锁进入时的单向链表
ObjectWaiter * volatile _cxq;
}
4.4 加锁释放锁过程
当线程获取Monitor
锁时,首先线程会被加入到_EntryList
队列当中,当某个线程获取到对象的monitor
锁后将ObjectMonitor
中的_owner
变量设置为当前线程,同时ObjectMonitor
中的计数器_count
加1即获得锁对象。
若持有monitor
的线程调用wait()
方法,将释放当前持有的monitor
,_owner
变量恢复为null
,_count
自减1,同时该线程进入_WaitSet
集合中等待被唤醒。若当前线程执行执行完毕也将释放monitor
,以便其它线程线程进入获取monitor
。
1.没有线程来竞争锁的时候,ObjectMonitor
的_owner
是null
。
2.现在有三个线程来获取对象锁,线程首先被封装成ObjectWait
对象,然后进入到_EntryList队列中
,竞争对象锁。
3.当t1获取到对象锁的时候,ObjectMonitor
对象的_owner
指向t1,_count
数量加1。
4.执行完毕之后释放锁,然后又进行下一轮锁竞争。
🍓四、锁优化
1. 适应性自旋锁
在轻量级锁中,当线程竞争不到锁的时候,是通过CAS
自旋一直去获取尝试获取锁,这样就不用放弃CPU
的执行时间片
这一过程有一个缺点就是如果持有锁的线程运行的时间很长的话,那么自旋的线程一直占用CPU
又不能执行就很浪费资源,可以通过JVM参数设置自旋的次数,如果超过这个次数还没有获取到锁就升级为重量级锁,挂起当前线程。
// 设置自旋次数上限
-XX:PreBlockSpin=10
为了优化自旋占用CPU
执行时间片,JVM
引入了适应性自旋,适应性自旋会根据前面获取锁的线程自旋的次数来自动调整。
如果前面的线程通过自选获取到了锁,那么JVM
会自动增加自旋上限次数。
如果前面的线程自旋很少能获取到锁,那么就会挂起当前线程,升级为重量级锁。
2. 锁消除
2.1 什么是锁消除
Java
代码在编译的时候,消除不可能存在共享资源竞争的锁,通过这种方式消除没必要的锁,减少无意义的请求锁时间。
锁消除的依据JIT
编译的时候进行逃逸分析,如果当前对象作用域只在方法的内部,那么JVM
就认为这个锁可以消除。
// 关闭锁消除
-XX:-EliminateLocks
// 开启逃逸分析
-XX:+DoEscapeAnalysis
// 开启锁消除
-XX:+EliminateLocks
2.2 代码示例
虽然Demo
类的synMethod
是一个同步方法,但是demo类是一个局部变量,根据逃逸分析该对象只会在栈上分配 属于线程私有的,所以会自动消除锁。
public class Demo {
public static void main(String[] args) {
Demo demo = new Demo();
demo.synMethod();
}
private synchronized void synMethod() {
System.out.println("同步方法");
}
}
3. 锁粗化
3.1 什么是锁粗化
锁粗化就是把锁的范围加大到整个同步代码的外部,这样能降低频繁的获取锁,从而提升性能。
如下面这段代码,在循环体内加锁,可以把锁加到循环体的外部,这样减少了加锁的次数,提升了性能
3.2 代码示例
for(int i = 0; i < 10; i++){
synchronized(lock){
}
}
synchronized(lock) {
for (int i = 0; i < 10; i++) {
}
}
参考资料
juejin.cn/post/723252…
锁升级图示