Java核心知识|面试必问系列 --- 多线程安全问题


多线程安全问题

  • 线程安全与不安全
    • 安全线程
    • 不安全线程
    • 线程执行方式
  • 线程不安全原因
    • 1. CPU 抢占执行(根本)
    • 2. 原子性(一块执行)
    • 3. 编译器优化(代码优化)
    • 4. (内存)可见性
    • 5. 多个线程修改了同一个变量
  • volatile 轻量级解决 “线程安全” 的方案
  • 线程安全通用解决方案
    • 加锁步骤
    • 1. synchronized 加锁
      • 实现原理
      • 锁升级过程
      • synchronized解决线程问题代码实例
      • synchronized 的使用场景
    • 2. Lock 手动锁
      • 代码实现
      • 公平锁与非公平锁
      • 用公平锁实现双线程打印“AABBCCDD”
    • volatile 和 synchronized 有什么区别?
    • synchronized 和 Lock 之间的区别?

线程安全与不安全 安全线程 线程安全定义:如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。
用主线程来执行增加减少操作,这是安全线程
static class Counter { // 定义的私有变量 private int num = 0; // 任务执行次数 private final int maxSize = 100000; //num++; public void incrment() { for (int i = 0; i < maxSize; i++) { num++; } }//num-- public void decrment() { for (int i = 0; i < maxSize; i++) { num--; } }public int getNum() { return num; } }public static void main(String[] args) { Counter counter = new Counter(); counter.incrment(); counter.decrment(); System.out.println("最终的执行结果:" + counter.getNum()); }

最后输出0
不安全线程 线程不安全定义:多线程执行中,程序的执行结果和预期不相符就叫作线程不安全
用两个线程分别执行增加,减少操作,这是不安全线程
static class Counter { // 定义的私有变量 private int num = 0; // 任务执行次数 private final int maxSize = 100000; //num++; public void incrment() { for (int i = 0; i < maxSize; i++) { num++; } }//num-- public void decrment() { for (int i = 0; i < maxSize; i++) { num--; } }public int getNum() { return num; } }public static void main(String[] args) throws InterruptedException { Counter counter = new Counter(); Thread t1 = new Thread(() -> { counter.incrment(); }); t1.start(); Thread t2 = new Thread(() -> { counter.decrment(); }); t2.start(); t1.join(); t2.join(); System.out.println("最终的执行结果:" + counter.getNum()); }

每次结果都不同,不为0
线程执行方式 线程执行操作会分为3步:
  1. load 读取操作:jvm中有一块区域叫作主内存,从中读取count的值
  2. calc 运算操作:读取到了count值后,进行运算操作,对应代码的 ++ 和 –
  3. save保存操作,把运算好的count值再保存回主内存中,一次循环操作才结束
线程不安全原因 1. CPU 抢占执行(根本) 2. 原子性(一块执行) 我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的
3. 编译器优化(代码优化) 编译器优化再单线程下没问题,可以提升程序的执行效率,单在多线程下就会出现混乱,从而导致线程不安全的问题
4. (内存)可见性 所以不难看出,连个线程并行执行时,每次都用的一个存储空间的值,每次保存都会覆盖这个count值,所以结果自然也就千奇百怪,这就是线程不安全的原因。
Java核心知识|面试必问系列 --- 多线程安全问题
文章图片

线程执行方式:
  1. 从 L1 缓存、L2缓存、主内存顺序查找并拿去数据到自己的线程工作站中,第一次从主内存中拿取
  2. 在工作站中进行运算操作
  3. 分别把运算后的操作放置到 L1 缓存、L2缓存、主内存中
  4. 在拿取数据的时候,就会拿取 L1 缓存中的内容了
【Java核心知识|面试必问系列 --- 多线程安全问题】这就内存的不可见性,可能会导致线程的不安全
我们来用代码举例理解:
private static boolean flag = false; public static void main(String[] args) { Thread t1 = new Thread(new Runnable() { @Override public void run() { while (!flag) {} System.out.println("终止执行"); } }); t1.start(); Thread t2 = new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("设置 flag 为 true"); flag = true; } }); t2.start(); }

执行结果
Java核心知识|面试必问系列 --- 多线程安全问题
文章图片

可以看到明明将flag改为false了,但是代码并不会终止,一直在运行
这是由于线程的不可见性导致线程不安全出现的情况。
Java核心知识|面试必问系列 --- 多线程安全问题
文章图片

线程的工作方式:
  1. 先去自己的工作内存中找变量
  2. 去主内存里面找变量
本来两个线程都会从主内存中取到flag变量,但是由于线程 t1 中的while循环体中没有任何代码,cpu这时候进行优化,每次调用的就是t1工作区中的数据,这个数据是没有改变的,t2只改变了主内存中的数据,所以导致了t1并没有接收到flag更改后的数据,这也就是线程安全中的可见性的问题。
5. 多个线程修改了同一个变量 解决方案:
(一)如果我们把 t1.join() 移动到 t2.start()之前,这样就相当于 t1 执行完后 t2 再执行,相当于串行操作,就不会有这种线程不安全的情况了
(二)让两个线程修改他们各自的变量
static class Counter {// 任务执行次数 private final int maxSize = 100000; //num++; public int incrment() { int num1 = 0; for (int i = 0; i < maxSize; i++) { num1++; } return num1; }//num-- public int decrment() { int num2 = 0; for (int i = 0; i < maxSize; i++) { num2--; } return num2; }}private static int num1 = 0; private static int num2 = 0; public static void main(String[] args) throws InterruptedException { Counter counter = new Counter(); Thread t1 = new Thread(() -> { num1 = counter.incrment(); }); t1.start(); Thread t2 = new Thread(() -> { num2 = counter.decrment(); }); t2.start(); t1.join(); t2.join(); System.out.println("最终的执行结果:" + (num1 + num2)); }

volatile 轻量级解决 “线程安全” 的方案 我们只需要将全局变量flag中加上volatile关键字就可以啦
private static volatile boolean flag = false;

执行结果:
Java核心知识|面试必问系列 --- 多线程安全问题
文章图片

volatile作用:
  1. 禁止指令重排序
  2. 解决线程可见性问题
    实现原理:当操作完变量之后,强制删除掉工作内存中的变量
注意事项:volatile不能解决原子性问题
线程安全通用解决方案
  1. CPU抢占调度(不能)
  2. 每个线程操作自己的变量(可能行):不通用,修改难度大
  3. 在关键代码上让所有的 CPU 排队执行,加锁。
加锁步骤 锁操作的关键步骤:
  1. 尝试获取(如果成功拿到锁加锁,否则排队等待)
  2. 释放锁
1. synchronized 加锁 【JVM 层面的解决方案,自动帮我们进行加锁和释放锁】
实现原理
操作层面:
  • synchronized的底层是使用操作系统的互斥锁mutex lock实现的。
JVM层面:
  • 当线程释放锁时,JMM会把该线程对应的工作内存中的共享变量刷新到主内存中
  • 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码 必须从主内存中读取共享变量
JAVA层面:
  • 锁对象 mutex
  • 锁存放的地方:变量的对象头
    synchronized用的锁是存在Java对象头里的
    Java核心知识|面试必问系列 --- 多线程安全问题
    文章图片
synchronized同步快对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;
synchronized (lock) { synchronized (lock) { number--; } }

因为有可重入的性值,当获取到lock这把锁后,无论加几层这把锁,都可以进入
同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入
锁升级过程
synchronized 在JDK 6 之前使用重量级锁实现的,性能非常低,所以用的并不多
JDK6 对synchronized 做了一个优化(锁升级):
Java核心知识|面试必问系列 --- 多线程安全问题
文章图片

synchronized解决线程问题代码实例
那我们用synchronized来解决之前线程不安全的问题
//全局变量 private static int number = 0; //循环次数 private static final int maxSize = 100000; public static void main(String[] args) throws InterruptedException { Object lock = new Object(); //+10w Thread t1 = new Thread(new Runnable() { @Override public void run() { for (int i = 0; i < maxSize; i++) { synchronized (lock) { number++; } } } }); t1.start(); //-10w Thread t2 = new Thread(new Runnable() { @Override public void run() { for (int i = 0; i < maxSize; i++) { synchronized (lock) { number--; } } } }); t2.start(); //等t1,t2线程执行完 t1.join(); t2.join(); System.out.println("运行结果为:" + number); }

可以看到,这时候我们进行加减时,最终结果为0
Java核心知识|面试必问系列 --- 多线程安全问题
文章图片

执行流程:
Java核心知识|面试必问系列 --- 多线程安全问题
文章图片

注意事项:在进行加锁操作的时候,同一组业业务一定是同一个锁对象
synchronized 的使用场景
  1. 使用 synchronized 来修饰代码块(加锁对象可以自定义);
Object lock = new Object(); synchronized (lock) { number++; }

  1. 使用 synchronized 来修饰静态方法(加锁对象时当前的类对象)
public static synchronized void increment() { for (int i = 0; i < maxSize; i++) { number++; } }

  1. 使用 synchronized 来修饰普通方法(加锁对象是当前类的实例)
public synchronized void increment() { for (int i = 0; i < maxSize; i++) { number++; } }

而 Lock 只能用来修饰代码块
2. Lock 手动锁 【程序员自己加锁和释放锁】
代码实现
java.util.concurrent.locks这个包里面 简称为 JUC
我们把之前报错的代码用 Lock 改进一下
//全局变量 private static int number = 0; //循环次数 private static final int maxSize = 100000; public static void main(String[] args) throws InterruptedException { //1.创建手动锁 Lock lock = new ReentrantLock(); //+10w Thread t1 = new Thread(new Runnable() { @Override public void run() { for (int i = 0; i < maxSize; i++) { //2.加锁 lock.lock(); try { number++; }finally { //3.释放锁 lock.unlock(); } } } }); t1.start(); //-10w Thread t2 = new Thread(new Runnable() { @Override public void run() { for (int i = 0; i < maxSize; i++) { lock.lock(); try { number--; }finally { lock.unlock(); } } } }); t2.start(); //等t1,t2线程执行完 t1.join(); t2.join(); System.out.println("运行结果为:" + number); }

注意事项:一定要把 lock()放在 try 外面
  • 如果j将 lock()方法放在 try 里面,那么当 try 里面的代码出现异常以后,那么就会执行 finally里面的释放锁的代码,单这个时候加锁还没成功,就去释放锁
  • 如果j将 lock()方法放在 try里面,那么当执行finally里面释放锁的代码时候就会报错(线程状态异常),释放锁的异常就会覆盖掉业务代码的异常报错,从而增加了排除错误成本
公平锁与非公平锁
公平锁可以按顺序进行执行,而非公平锁执行的效率更高
在 java 中所有的锁默认的策略都是非公平锁(synchronized锁机制就是非公平锁)
Lock 默认的锁策略也是非公平锁,但是 Lock 可以显式地声明为公平锁
Lock lock = new ReentrantLock(true);

只需要在参数设置为 true 就可以设置为公平锁
用公平锁实现双线程打印“AABBCCDD”
public static void main(String[] args) throws InterruptedException { //声明一个公平锁 Lock lock = new ReentrantLock(true); Runnable runnable = new Runnable() { @Override public void run() { for(char item : "ABCD".toCharArray()) { lock.lock(); try { System.out.print(item); }finally { lock.unlock(); } } } }; Thread t1 = new Thread(runnable,"t1"); Thread t2 = new Thread(runnable,"t2"); Thread.sleep(10); t1.start(); t2.start(); }

volatile 和 synchronized 有什么区别?
  1. volatile 可以解决内存可见性问题 和 禁止指令重排序,但 volatile 不能解决原子性问题;
  2. synchronized 是用来保证线程安全的,也就是synchronized 可以解决任何关于线程安全的问题(关键代码排队执行,始终只有一个线程会执行加锁操作;原子性问题 等 )
synchronized 和 Lock 之间的区别?
  1. synchronized 既可以修饰代码块,又可以修饰静态方法或者普通方法;
    而Lock 只能修饰代码块
  2. synchronized 只有非公平锁策略,而 Lock 既可以是公平锁也可以是非公平锁(RentrantLock 默认是非公平锁,也可以通过构造函数设置 true 声明它为公平锁)
  3. ReentrantLock 更加灵活(比如 try Lock)
  4. synchronized 是自动加锁和释放锁的,而 ReentrantLock 需要自己手动加锁和释放锁

    推荐阅读