众所周知,使用多线程可以极大地提升程序的性能,但如果多线程使用不合理,也会带来很多不可控的问题,例如线程安全问题。
什么是线程安全问题呢?如果多个线程同时访问某个方法时,这个方法无法得到我们预期的结果,那么就认为这个方法是线程不安全的。
1.经典案例:num++线程安全问题
先来看个线程不安全的示例:
public class UnSafeTest {
@SneakyThrows
public static void main(String[] args) {
CountDownLatch countDownLatch = new CountDownLatch(10);
CustomService customService = new CustomService(countDownLatch);
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 100; j++) {
customService.add();
}
countDownLatch.countDown();
}).start();
}
// 等待其他线程执行完毕
countDownLatch.await();
System.out.println("num:" + customService.getNum());
}
static class CustomService {
private int num = 0;
public void add() {
num++;
}
public int getNum() {
return num;
}
}
}
在上述代码中启动了10个线程,每个线程使得变量num累加100次,期望结果是1000,但打印结果却始终小于1000,这是一个典型的线程安全问题。
我们在多线程环境下调用了CustomService.add()
,因而导致线程不安全,那么为什么会有线程安全问题呢?我们来看关键代码:
public void add() {
num++;
}
num++
,咋一看是不是觉得就是一个指令呀,但是实际上并不是我们想的那样。这就需要我们以字节码
信息来分析了。
可以使用javap -v xx.class
进行查看。
0 aload_0
1 dup
2 getfield #2 <com/example/demo/thread/UnSafeTest$CustomService.num : I>
5 iconst_1
6 iadd
7 putfield #2 <com/example/demo/thread/UnSafeTest$CustomService.num : I>
10 return
可以发现num++
实际上由3个指令组成getfield
、iadd
、putfield
组成。
- getfield:获取变量值。
- iadd:执行+1。
- putfield:设置变量值。
既然num++
实际上由3个字节码指令组成,那么在多线程环境中就无法保证其执行过程的原子性
。
上面这种情况是原子性
问题导致线程不安全,其实导致线程不安全的原因主要有3个:原子性、可见性、有序性,下面我们来看看它们的定义:
1)原子性
原子性主要保证一个或多个指令在执行过程中不允许被中断。
2)可见性
可见性主要保证一个线程对共享变量修改后,其他的线程立即能看到修改后的新值
3)有序性
有序性主要保证单线程下程序运行结果的正确性,即使编译器和处理器为了优化性能会对指令进行重排序
那么怎么解决线程安全问题呢?在Java中,我们可以使用synchronized
关键字来解决线程安全问题中的原子性
、可见性
、有序性
。
2.synchronized解决线程安全问题
synchronized是一个同步锁,在同一时刻,被修饰的方法或代码块只有一个线程能执行,以保证线程安全。很多人都称之为重量级锁,但是,随着JDK1.6对synchronized进行了各种优化之后,在某些场景下它就并不那么重了。
既然synchronized
可以帮助我们解决线程安全问题,那么到底怎么使用呢?话不多说,上代码:
public synchronized void add() {
num++;
}
我们只是在add()
方法上添加了synchronized
关键字,就能够保证多线程环境下的线程安全了。当然这只是synchronized
关键字的一种使用方式。我们来看看synchronized
关键字的所有使用方式吧。
2.1 synchronized使用方式
2.1.1 修饰实例方法
synchronized修饰实例方法,锁对象为当前实例对象,也称之为对象锁,进入同步代码需要获取当前实例对象的锁。进入同步实例方法时,如果锁对象不是同一实例对象,则不会形成资源竞争,线程之间互不影响。
// 修饰实例方法
public synchronized void test1() {
// 省略代码
......
}
多线程调用同一锁对象的不同实例方法。
示例代码:
public class SynchronizedTest {
public static void main(String[] args) {
CustomService customServiceA = new CustomService();
new Thread(() -> customServiceA.test1(), "ThreadA").start();
new Thread(() -> customServiceA.test2(), "ThreadB").start();
}
static class CustomService {
// synchronized修饰实例方法
@SneakyThrows
public synchronized void test1() {
TimeUnit.SECONDS.sleep(1);
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + " method:test1 :" + i);
}
}
// synchronized修饰实例方法
@SneakyThrows
public synchronized void test2() {
TimeUnit.SECONDS.sleep(1);
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + " method:test2 :" + i);
}
}
}
}
执行结果:
虽然多个线程分别调用了不同的Synchronized修饰的实例方法,但是这些方法的锁对象是同一个,因此形成资源竞争,同一时刻只能有一个线程执行同步代码。
2.1.2 修饰静态方法
synchronized修饰静态方法,锁对象为当前类的Class对象,也称之为类锁,进入同步代码前需要获取类的Class对象的锁。
// 修饰静态方法
public static synchronized void test3() {
// 省略代码
......
}
示例代码:
public class SynchronizedTest {
@SneakyThrows
public static void main(String[] args) {
CustomService customServiceA = new CustomService();
new Thread(() -> customServiceA.test1(), "ThreadA").start();
TimeUnit.SECONDS.sleep(1);
new Thread(() -> customServiceA.test2(), "ThreadB").start();
}
static class CustomService {
// 修饰静态方法
@SneakyThrows
public static synchronized void test3() {
TimeUnit.SECONDS.sleep(2);
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + " method:test3 :" + i);
}
}
// 修饰实例方法
@SneakyThrows
public static void test4() {
TimeUnit.SECONDS.sleep(1);
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + " method:test4 :" + i);
}
}
}
}
执行结果:
ThreadA先获得CustomService的Class对象锁,这是一个类锁(全局锁),而后ThreadB来获取CustomService的实例对象锁,虽然不是同一个锁对象,但是类锁是一个全局锁,因此形成资源竞争,同一时刻只能有一个线程执行同步代码。
2.1.3 修饰代码块
synchronized修饰代码块,取决于锁对象,对给定对象加锁,进入同步代码则需要获取给定对象的锁。
public void test5() {
// 修饰代码块,锁对象为实例对象
synchronized (this) {
// 省略代码
......
}
}
public void test6() {
// 修饰代码块,锁对象为实例对象
synchronized (new Object()) {
// 省略代码
......
}
}
public void test7() {
// 修饰代码块,锁对象为类的Class对象
synchronized (Object.class) {
// 省略代码
......
}
}
synchronized
修饰代码块,重点在于锁对象,锁对象为实例对象时,其效果与修饰实例方法一致,锁对象为类的Class对象时,其效果与修饰静态方法一致。
2.2 synchronized锁标记存储
到这里,我们已经知道锁对象可以是实例对象,也可以是类的Class对象,其实这个锁对象就是线程竞争的资源,那么怎么标记某线程获得了该锁,从而使得其他线程无法同时获得该锁,这就需要我们了解堆中对象的存储结构
。
我们知道,一个Java对象被初始化之后会存储在堆内存中,那么在堆中存储了对象的哪些信息呢?
Java对象存储结构分为3个部分:对象头
、实例数据
、对齐填充
。
从上图中可以看到,对象头中Mark Word
存储了锁相关的信息,我们来看看Mark Word
的存储结构。
内置锁状态 | 锁标记位(2bit) | 是否偏向锁(1bit) | 存储内容 |
---|---|---|---|
无锁 | 01 | 0 | 对象哈希码、GC分代年龄 |
偏向锁 | 01 | 1 | 偏向线程id,偏向时间戳、对象分代年龄 |
轻量级锁 | 00 | 空 | 锁记录(Lock Record)指针 |
重量级锁 | 10 | 空 | 重量级锁指针 |
GC标记 | 11 | 空 | 空 |
我们知道2bit最多只能表示四种状态:00,01,10,11,因此Mark Word
额外通过1bit来表示无锁和偏向锁,0表示无锁,1表示偏向锁。
从表格得知,Mark Word的存储结构会随着内置锁状态的变化而变化,这也是我们需要注意的地方。
2.3 synchronized的锁升级
从堆中对象的存储结构,我们可以知道,Mark Word
中存储了内置锁状态,这正是synchronized
所依赖的,那么为什么会有这几种状态呢?
前面我们提到过,很多人称synchronized
是重量级锁,性能不好,其实这种说法并不完全正确。
在JDK1.6之前synchronized
确实是一个重量级锁
,没有获得锁的线程会被阻塞,由用户态切换到内核态,这样切换的性能开销是非常大的。
因此,JDK1.6之后对synchronized
做了很多优化,引入了偏向锁
和轻量级锁
,使得某些场景下,锁就不那么重了(让线程在不阻塞的情况下达到线程安全)。
那么到底是怎么优化的呢?我们来看看偏向锁、轻量级锁、重量级锁的定义,这个疑问就不言而喻了。
2.3.1 偏向锁
在单线程环境下,访问synchronized修饰的同步代码,这个时候的锁状态就是偏向锁。
对于这个定义,可能大家会有疑问,既然是单线程环境,没有线程竞争,为什么还要加偏向锁呢?
因为,在实际开发中,加锁是为了避免出现线程安全问题,是否存在线程竞争是由应用场景决定的。假设存在这种情况,没必要一上来就使用重量级锁,这样显然很消耗性能。
我们来看看获得偏向锁的过程,自然就能明白其出现的必要性。
1)在没有线程竞争的情况下,线程A去访问Synchronized
修饰的方法/代码块。
2)尝试通过偏向锁来获取锁资源(基于CAS)。
3)如果获取锁资源成功,则修改Mark Word
中的锁标记,偏向锁标记为1,锁标记为01,并存储获得锁资源的线程id,然后执行代码。
4)如果同一线程再来访问,直接获取锁资源,然后执行代码。
2.3.2 轻量级锁
在没有线程竞争的场景下,使用偏向锁只允许同一时刻一个线程获取锁资源,如果这时有其他的线程来访问同步代码,没有获取到线程资源的线程应该怎么处理呢?
显然偏向锁无法解决这个问题,如果直接按照重量级锁的逻辑来解决,没有获取到锁资源的线程阻塞等待,必然会造成很大的性能消耗,于是乎,轻量级锁就出现了,偏向锁升级为轻量级锁。
所谓的轻量级锁,就是未获取到锁资源的线程,进行一定次数的自旋,重新尝试获取锁,如果在重试过程中获取到锁资源,那么此线程就不需要阻塞。
我们来看看获取轻量级锁的过程:
1)线程A已获取偏向锁。
2)线程B开始竞争锁资源,锁对象的线程id与线程B的线程id不一致,意味着出现锁竞争
3)在线程B的栈帧中创建锁记录Lock Record
,用于存储锁对象的Mark Word
旧信息以及锁对象地址,并将Mark Word
中的信息,例如对象哈希码、GC分代年龄拷贝到Lock Record
中的Displaced Mark Word
中,以便后续锁释放时使用。
4)自旋尝试将Mark Word
中锁指针记录更新为线程B栈帧中Lock Record
的地址。
5)如果更新成功,则修改Mark Word
中锁标记修改为00,偏向锁标记为0,并将Lock Record
的owner指针
指向当前锁对象。
Tips:
自旋重试过程中,会一直占用CPU资源,如果持有锁的线程占用锁资源的时间比较短,自旋会明显地提升性能,如果持有锁的线程占用锁资源的时间比较长,那么自旋就会浪费CPU资源,因此需要限制线程自旋的次数。
在JDK 1.6中默认的自旋次数是10次,我们可以通过**-XX:PreBlockSpin**参数来调整自旋次数。同时还引入的自适应自旋锁,来解决“锁竞争时间不确定”的问题,尽可能减少自旋次数。
2.3.3 重量级锁
轻量级锁能够让没能获取锁资源的线程进行一定次数的自旋重试有机会获取到锁资源,但如果持有锁的线程占用锁资源的时间较长,总不能让那些自旋了一定次数还是没有获取到锁资源的线程一直自旋下去吧,这样反而会占用很多的CPU资源,唯一的解决办法就是让这些线程阻塞等待,最终轻量级锁—》重量级锁。
重量级锁依赖于系统层面的Mutex Lock
,会使线程阻塞,并由用户态
转为内核态
,这种切换的性能开销非常大。
2.3.4 锁升级过程
因此,锁的状态可以为无锁、偏向锁、轻量级锁、重量级锁。锁的级别从低到高为:无锁—》偏向锁—》轻量级锁—》重量级锁。注意:升级并不一定是一级一级生的,比如:直接由无锁状态升级为轻量级锁。
2.4 synchronized原理
毋庸置疑,使用synchronized
关键字,可以非常快速地帮助我们解决线程安全问题,那么它到底是怎么实现的呢?
我们先来看看使用synchronized修饰的代码的字节码。
public class CustomService {
private static int num;
public synchronized void test1() {
num++;
}
public static synchronized void test2() {
num++;
}
public void test3() {
synchronized (this) {
num++;
}
}
}
通过javap -v xx.class
查看对应的字节码指令:
test1():
test2():
test3():
可以得出以下结论:
1)synchronized
修饰方法时,会在访问标识符(flags)中加入ACC_SYNCHRONIZED
标识。
官方解释为:方法级同步是隐式执行的。当调用这些方法时,如果发现会
ACC_SYNCHRONIZED
标识,则会进入一个monitor
,执行方法,然后退出monitor
。无论方法调用正常还是发生异常,都会自动退出monitor
,也就是释放锁。
2)synchronized
修饰代码块时,会增加monitorenter
和monitorexit
指令。
官方解释为:每个对象都与一个监视器monitor关联,执行monitorenter指令的线程尝试获取锁对象关联的监视器monitor的所有权,如果monitor的计数为0,则该线程获得锁,并将计数+1,此时其他线程将阻塞等待,直到该计数为0时,其他线程才有机会来获取监视器monitor的所有权。
因此,synchronized
关键字的底层是通过每个对象关联的监视器monitor
来实现的,每个对象关联一个监视器monitor
,线程通过修改monitor
的计数值来获取和释放锁。