前言

本文主要内容

  • 介绍出现线程安全问题的本质原因
  • 介绍解决线程安全问题的方式及这种方式带来的新问题

正文

线程安全问题的本质原因

线程切换造成的原子性问题

首先这里要说明一下,对于协同式线程调度,因为线程执行时间由线程决定,所以理论上来讲,是不会因线程切换带来原子性问题的。所以,这里完整说应该是抢占式线程调度中,线程切换带来的原子性问题。但是 java 的线程实现依赖操作系统,而现在的操作系统一般使用的都是抢占式线程调度,所以,默认下线程调度指的就是抢占式好了~
回到正题。首先介绍下什么是原子性。原子性指的就是不可再分割,举例来讲,如果说某个线程的行为具有原子性,那么对于另一个线程来讲,这个行为只有两种状态,未执行和执行完。对于操作系统来讲,它的一个指令是原子性,而对于java程序,它的一个语句的执行需要多个操作系统的指令,这些指令组成的行为是不保证原子性的。
比如下面这个例子
image.png
对于操作系统来讲这个过程需要

  • 读取
  • 运算
  • 写入

那么可能当一个线程(设为A)在读取时,时间到了,之后由另一个线程(B)开始进行,进行这些操作。那么当B的时间到了,由回到了A线程,继续之前的操作,运算,写入。那B线程所进行的增加操作就被覆盖了。这就造成了线程的安全问题

缓存带来的可见性问题

可见性,简单的说就是一个线程对共享资源的修改可以立刻被其它线程知道。
因为CPU与内存之间的I/O速度差距巨大,与内存协作时,CPU等待时间过长,就影响了CPU性能。为了解决这个问题,引入了多级缓存。大概就是这个样子
image.png
对于单核CPU,不需要考虑这个问题。但是对于多核心的,因为L1 Cache在各个核心中并不共享,这里就可能出现缓存不一致的问题。比如说,核心一的某个线程修改了共享变量A,但是此时还没有写入共享的缓存,还在L1 Cache,其它的核心中的线程并不知道进行了修改,如果使用这个变量进行操作,就会出现线程安全问题

编译优化带来的有序性问题

虽然程序是按语句顺序执行,但是还是因为有的指令进行的快,有的指令进行的慢,因此编译器可能会对其进行优化。举一个知名度比较高的例子,双重检测单例模式
image.png
即使这样,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保证可见性

来看下这个例子

  1. public class HappendsBeforeTest {
  2. private static int i =0;
  3. private volatile static boolean j = false;
  4. public static void main(String[] args) throws InterruptedException {
  5. Thread t1 = new Thread(()->{
  6. i = 1;
  7. j = true;
  8. try {
  9. Thread.sleep(100);
  10. } catch (InterruptedException e) {
  11. e.printStackTrace();
  12. }
  13. });
  14. Thread t2 = new Thread(()->{
  15. if(j){
  16. System.out.println(i);
  17. }else{
  18. System.out.println("wait...");
  19. }
  20. });
  21. t1.start();
  22. t2.start();
  23. t1.join();
  24. t2.join();
  25. }
  26. }

定义两个线程共享变量 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 的锁,于是阻塞

因此两个线程都在阻塞中,占用资源
代码实现起来就是这样

  1. public class DeadLock {
  2. public static void main(String[] args) throws InterruptedException {
  3. Object o1 = new Object();
  4. Object o2 = new Object();
  5. Thread t1 = new Thread(()->{
  6. synchronized (o1){
  7. try {
  8. //为了增大死锁的概率,等等另一个线程
  9. Thread.sleep(10);
  10. } catch (InterruptedException e) {
  11. e.printStackTrace();
  12. }
  13. synchronized (o2){
  14. System.out.println("t1");
  15. }
  16. }
  17. });
  18. Thread t2 = new Thread(()->{
  19. synchronized (o2){
  20. synchronized (o1){
  21. System.out.println("t2");
  22. }
  23. }
  24. });
  25. t1.start();
  26. t2.start();
  27. t1.join();
  28. t2.join();
  29. }
  30. }

检测死锁

使用 jconsole 检测死锁

image.png
image.png
image.png
image.png
image.png

死锁产生的条件(需要全部满足)

  • 互斥,一个共享资源只可以被一个线程持有
  • 占有且等待,一个占用一个共享资源后还在等待占用另一个共享资源
  • 不可抢占,已经被占用的资源除非线程自己释放,否则其他线程不可占用
  • 循环等待,线程 t1等待着 t2占用的资源,线程 t2 等待着线程 t1 占用的资源,两个线程都不放弃占用,一直等待

    解决死锁问题

    只需要破坏死锁产生的条件,就可以了。一个就行
    对于互斥,这个是不可以破坏了,否则就是失去了使用锁的意义

    破坏占用且等待

    主要是获取锁的条件产生了死循环,那么解决也很简单
    扩大锁的粒度:只有获取了所有的条件才进行上锁,比如上面例子中的,只有同时获取了o1,o2,才可以获取锁
    也就是可以改成这个样子 ```java public class DeadLock1 { static class O {

    1. private Object o1;
    2. private Object o2;
    3. public O(Object _o1, Object _o2) {
    4. o1 = _o1;
    5. o2 = _o2;
    6. }

    }

    public static void main(String[] args) throws InterruptedException {

    1. Object o1 = new Object();
    2. Object o2 = new Object();
    3. O o = new O(o1, o2);
    4. Thread t1 = new Thread(() -> {
    5. synchronized (o) {
    6. System.out.println("t1");
    7. }
    8. });
    9. Thread t2 = new Thread(() -> {
    10. synchronized (o) {
    11. System.out.println("t2");
    12. }
    13. });
    14. t1.start();
    15. t2.start();
    16. t1.join();
    17. t2.join();

    }

} ```

破坏不可抢占条件

这里可以改为如果获取不到锁就释放资源嘛,这样就不会阻塞了
这里 synchronized 是做不到的,需要使用 JDK 实现的锁机制

破坏循环等待条件

可以通过给资源编号,然后按顺序申请需要的资源,以此来避免条件的循环等待

锁死

当线程占用锁时间过长,使得其他线程无法获取这个资源的锁时,就会造成锁死,解决方法也很简单,就是减小锁的粒度

活锁

这一个问题可能会由 JDK 实现的锁机制产生,是破坏循环等待条件时可能产生的新问题。过程可能是这个样子

  • 线程 A 占用资源 o1,想要获取 o2锁,获取 o2 的锁失败,释放
  • 线程 B 占用资源 o2,想要获取 o1锁,获取 o1 的锁失败,释放

然后就这样一直循环下去,与死锁不同的是,死锁中的两个线程是阻塞态,而活锁中的两个线程是 执行态,会占用着 CPU 的资源。这就好像,某条路上有两人相向而行,双方都想让路,同时往左,同时往右,结果谁都没过去。
解决的方法也很简单,这个主要是一同释放又恰巧同时占用了各自需要的锁。可以通过让一个线程“等一等”来结束这个死循环过程

饥饿

这个是因为非公平锁的问题,一般来讲获取锁失败的线程会放入等待队列当中,等待锁释放,然后按队列中的顺序,进行持有资源。但是非公平锁中,不在等待队列中的线程抢占资源时,只检查有没有线程持有,而不会检查等待队列中是否有线程在等待,如果没有线程持有就直接持有资源了。这就可能会使得某些线程永远无法获得共享资源的锁,这就是线程饥饿

参考