前言
本文主要内容
- 介绍出现线程安全问题的本质原因
- 介绍解决线程安全问题的方式及这种方式带来的新问题
正文
线程安全问题的本质原因
线程切换造成的原子性问题
首先这里要说明一下,对于协同式线程调度,因为线程执行时间由线程决定,所以理论上来讲,是不会因线程切换带来原子性问题的。所以,这里完整说应该是抢占式线程调度中,线程切换带来的原子性问题。但是 java 的线程实现依赖操作系统,而现在的操作系统一般使用的都是抢占式线程调度,所以,默认下线程调度指的就是抢占式好了~
回到正题。首先介绍下什么是原子性。原子性指的就是不可再分割,举例来讲,如果说某个线程的行为具有原子性,那么对于另一个线程来讲,这个行为只有两种状态,未执行和执行完。对于操作系统来讲,它的一个指令是原子性,而对于java程序,它的一个语句的执行需要多个操作系统的指令,这些指令组成的行为是不保证原子性的。
比如下面这个例子
对于操作系统来讲这个过程需要
- 读取
- 运算
- 写入
那么可能当一个线程(设为A)在读取时,时间到了,之后由另一个线程(B)开始进行,进行这些操作。那么当B的时间到了,由回到了A线程,继续之前的操作,运算,写入。那B线程所进行的增加操作就被覆盖了。这就造成了线程的安全问题
缓存带来的可见性问题
可见性,简单的说就是一个线程对共享资源的修改可以立刻被其它线程知道。
因为CPU与内存之间的I/O速度差距巨大,与内存协作时,CPU等待时间过长,就影响了CPU性能。为了解决这个问题,引入了多级缓存。大概就是这个样子
对于单核CPU,不需要考虑这个问题。但是对于多核心的,因为L1 Cache在各个核心中并不共享,这里就可能出现缓存不一致的问题。比如说,核心一的某个线程修改了共享变量A,但是此时还没有写入共享的缓存,还在L1 Cache,其它的核心中的线程并不知道进行了修改,如果使用这个变量进行操作,就会出现线程安全问题
编译优化带来的有序性问题
虽然程序是按语句顺序执行,但是还是因为有的指令进行的快,有的指令进行的慢,因此编译器可能会对其进行优化。举一个知名度比较高的例子,双重检测单例模式
即使这样,instance仍存在线程安全问题。对于 instance = new Singleton(); 这一过程,主要有三步骤
- 申请内存
- 初始化
- 将地址传递给 instance
但是编译器可能优化为
- 申请内存
- 将地址传递给 instance
- 初始化
如果在传递地址时,线程的时间片结束。另一个线程开始运行,在进行到 instance 判空时,此时 instance 不为null,但是并未初始化,之后该线程在使用到 instance 时,就可能出现空指针异常
解决线程安全问题
以上问题,都可以使用一种方式来解决,那就是互斥-同步。互斥保证同一时间,只有一个线程对需要线程安全的线程进行操作;同步保证线程对资源的修改使其它线程可见。但是如果粒度过大,就失去了多线程的意义,需要根据需求进行选择互斥-同步的方案。对于原子性是要使用互斥的,但是对于可见性和有序性,可以使用更轻量的方式
使用volatile解决变量的可见性问题
从上面可以知道,可见性问题来源于缓存的不一致问题。解决这个问题的方法很简单粗暴,就是不用缓存~
使用内存屏障指令,对于volatile 修饰的变量,要求读取从内存中读取,修改后立即写入内存中。
使用Happens-Before规则解决可见性和有序性问题
Happens-Before是 JMM 规范的一部分。它的意义是前面一个操作产生的结果对后续操作可见。
常用的规则及性质如下:
- 次序规则:在一个线程内,程序按代码顺序执行
- 锁定规则:unlock先发生于lock
- volatile规则:对volatile修饰的变量的写操作先发生于读操作
- 线程启动规则:start()先行发生于之后的所有动作
- 线程终止规则:线程所有操作发生在该线程终止之前
- 线程中断规则:interrupt()方法的调用先于检测到中断
- 对象终结规则:对象的初始化完成发生在该对象finalize()之前
- 传递性:如果 A Happens-Before B , B Happens-Before C。那么 A Happens-Before C
使用Happens-Before保证可见性
来看下这个例子
public class HappendsBeforeTest {
private static int i =0;
private volatile static boolean j = false;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
i = 1;
j = true;
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Thread t2 = new Thread(()->{
if(j){
System.out.println(i);
}else{
System.out.println("wait...");
}
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}
定义两个线程共享变量 i,j.其中 j 用 volatile 修饰。然后创建两个线程,一个线程写,一个线程读。
写的时候,i 值改变为 1 , j 修改为 true.
读时,若j 为 true , 输出 i 值,否则输出 wait…
这里操作少,时间片够用了,至于硬件突然 I/O 速度变得极慢这种情况就先不考虑了,所以原子性就不用考虑了。并且赋值操作也简单,不需要考虑有序性问题,就只先考虑可见性问题。如果是在 jdk 1.5之前,没有 Happends-Before ,因为缓存问题,在读线程中可能输出 0
而在引入 Happends-Before 之后,读线程是不可能输出 0 的。来分析一下
- 由次序规则可得,i = 1 先于 j = true 或 i = 0 先于 j = false
- 由 volatile规则可得,j = 0 或 j = true 先于 if(j)
- 因此由传递性可得
- i = 0 先于 if(j),此时写线程还没开始写,j == false ,所以输出 wait…
- i = 1 先于 if(j),此时写线程已经写入,j == true,所以输出 1
互斥带来新的问题及解决方式
对于原子性问题,可以使用互斥来解决,但是互斥又会引入新的问题
死锁
简单来讲,死锁的情形就是这样:
- 线程 t1 已经获取资源 o1 的锁,但是想要继续进行需要获取资源 o2 的锁,于是阻塞
- 线程 t2 已经获取资源 o2 的锁,但是想要继续进行需要获取资源 o1 的锁,于是阻塞
因此两个线程都在阻塞中,占用资源
代码实现起来就是这样
public class DeadLock {
public static void main(String[] args) throws InterruptedException {
Object o1 = new Object();
Object o2 = new Object();
Thread t1 = new Thread(()->{
synchronized (o1){
try {
//为了增大死锁的概率,等等另一个线程
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o2){
System.out.println("t1");
}
}
});
Thread t2 = new Thread(()->{
synchronized (o2){
synchronized (o1){
System.out.println("t2");
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}
检测死锁
使用 jconsole 检测死锁
死锁产生的条件(需要全部满足)
- 互斥,一个共享资源只可以被一个线程持有
- 占有且等待,一个占用一个共享资源后还在等待占用另一个共享资源
- 不可抢占,已经被占用的资源除非线程自己释放,否则其他线程不可占用
循环等待,线程 t1等待着 t2占用的资源,线程 t2 等待着线程 t1 占用的资源,两个线程都不放弃占用,一直等待
解决死锁问题
只需要破坏死锁产生的条件,就可以了。一个就行
对于互斥,这个是不可以破坏了,否则就是失去了使用锁的意义破坏占用且等待
主要是获取锁的条件产生了死循环,那么解决也很简单
扩大锁的粒度:只有获取了所有的条件才进行上锁,比如上面例子中的,只有同时获取了o1,o2,才可以获取锁
也就是可以改成这个样子 ```java public class DeadLock1 { static class O {private Object o1;
private Object o2;
public O(Object _o1, Object _o2) {
o1 = _o1;
o2 = _o2;
}
}
public static void main(String[] args) throws InterruptedException {
Object o1 = new Object();
Object o2 = new Object();
O o = new O(o1, o2);
Thread t1 = new Thread(() -> {
synchronized (o) {
System.out.println("t1");
}
});
Thread t2 = new Thread(() -> {
synchronized (o) {
System.out.println("t2");
}
});
t1.start();
t2.start();
t1.join();
t2.join();
}
} ```
破坏不可抢占条件
这里可以改为如果获取不到锁就释放资源嘛,这样就不会阻塞了
这里 synchronized 是做不到的,需要使用 JDK 实现的锁机制
破坏循环等待条件
可以通过给资源编号,然后按顺序申请需要的资源,以此来避免条件的循环等待
锁死
当线程占用锁时间过长,使得其他线程无法获取这个资源的锁时,就会造成锁死,解决方法也很简单,就是减小锁的粒度
活锁
这一个问题可能会由 JDK 实现的锁机制产生,是破坏循环等待条件时可能产生的新问题。过程可能是这个样子
- 线程 A 占用资源 o1,想要获取 o2锁,获取 o2 的锁失败,释放
- 线程 B 占用资源 o2,想要获取 o1锁,获取 o1 的锁失败,释放
然后就这样一直循环下去,与死锁不同的是,死锁中的两个线程是阻塞态,而活锁中的两个线程是 执行态,会占用着 CPU 的资源。这就好像,某条路上有两人相向而行,双方都想让路,同时往左,同时往右,结果谁都没过去。
解决的方法也很简单,这个主要是一同释放又恰巧同时占用了各自需要的锁。可以通过让一个线程“等一等”来结束这个死循环过程
饥饿
这个是因为非公平锁的问题,一般来讲获取锁失败的线程会放入等待队列当中,等待锁释放,然后按队列中的顺序,进行持有资源。但是非公平锁中,不在等待队列中的线程抢占资源时,只检查有没有线程持有,而不会检查等待队列中是否有线程在等待,如果没有线程持有就直接持有资源了。这就可能会使得某些线程永远无法获得共享资源的锁,这就是线程饥饿