🍇一、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包
首先导入相关的包
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
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;
}
objectMonitor源码解析以及重量级锁底层实现原理参考:Java多线程:objectMonitor源码解读(3)
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++) {
}
}
参考资料