面试必问系列 --- 多线程安全问题
Posted 满眼*星辰
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了面试必问系列 --- 多线程安全问题相关的知识,希望对你有一定的参考价值。
线程安全与不安全
安全线程
线程安全定义:如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。
用主线程来执行增加减少操作,这是安全线程
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步:
- load 读取操作:jvm中有一块区域叫作主内存,从中读取count的值
- calc 运算操作:读取到了count值后,进行运算操作,对应代码的 ++ 和 –
- save保存操作,把运算好的count值再保存回主内存中,一次循环操作才结束
线程不安全原因
1. CPU 抢占执行(根本)
2. 原子性(一块执行)
我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的
3. 编译器优化(代码优化)
编译器优化再单线程下没问题,可以提升程序的执行效率,单在多线程下就会出现混乱,从而导致线程不安全的问题
4. (内存)可见性
所以不难看出,连个线程并行执行时,每次都用的一个存储空间的值,每次保存都会覆盖这个count值,所以结果自然也就千奇百怪,这就是线程不安全的原因。
线程执行方式:
- 从 L1 缓存、L2缓存、主内存顺序查找并拿去数据到自己的线程工作站中,第一次从主内存中拿取
- 在工作站中进行运算操作
- 分别把运算后的操作放置到 L1 缓存、L2缓存、主内存中
- 在拿取数据的时候,就会拿取 L1 缓存中的内容了
这就内存的不可见性,可能会导致线程的不安全
我们来用代码举例理解:
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();
}
执行结果
可以看到明明将flag改为false了,但是代码并不会终止,一直在运行
这是由于线程的不可见性导致线程不安全出现的情况。
线程的工作方式:
- 先去自己的工作内存中找变量
- 去主内存里面找变量
本来两个线程都会从主内存中取到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;
执行结果:
volatile作用:
- 禁止指令重排序
- 解决线程可见性问题
实现原理:当操作完变量之后,强制删除掉工作内存中的变量
注意事项:volatile不能解决原子性问题
线程安全通用解决方案
- CPU抢占调度(不能)
- 每个线程操作自己的变量(可能行):不通用,修改难度大
- 在关键代码上让所有的 CPU 排队执行,加锁。
加锁步骤
锁操作的关键步骤:
- 尝试获取(如果成功拿到锁加锁,否则排队等待)
- 释放锁
1. synchronized 加锁
【JVM 层面的解决方案,自动帮我们进行加锁和释放锁】
实现原理
操作层面:
- synchronized的底层是使用操作系统的互斥锁mutex lock实现的。
JVM层面:
- 当线程释放锁时,JMM会把该线程对应的工作内存中的共享变量刷新到主内存中
- 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码 必须从主内存中读取共享变量
JAVA层面:
- 锁对象 mutex
- 锁存放的地方:变量的对象头
synchronized用的锁是存在Java对象头里的
synchronized同步快对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;
synchronized (lock) {
synchronized (lock) {
number--;
}
}
因为有可重入的性值,当获取到lock这把锁后,无论加几层这把锁,都可以进入
同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入
锁升级过程
synchronized 在JDK 6 之前使用重量级锁实现的,性能非常低,所以用的并不多
JDK6 对synchronized 做了一个优化(锁升级):
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
执行流程:
注意事项:在进行加锁操作的时候,同一组业业务一定是同一个锁对象
synchronized 的使用场景
- 使用 synchronized 来修饰代码块(加锁对象可以自定义);
Object lock = new Object();
synchronized (lock) {
number++;
}
- 使用 synchronized 来修饰静态方法(加锁对象时当前的类对象)
public static synchronized void increment() {
for (int i = 0; i < maxSize; i++) {
number++;
}
}
- 使用 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 有什么区别?
- volatile 可以解决内存可见性问题 和 禁止指令重排序,但 volatile 不能解决原子性问题;
- synchronized 是用来保证线程安全的,也就是synchronized 可以解决任何关于线程安全的问题(关键代码排队执行,始终只有一个线程会执行加锁操作;原子性问题 等 )
synchronized 和 Lock 之间的区别?
- synchronized 既可以修饰代码块,又可以修饰静态方法或者普通方法;
而Lock 只能修饰代码块 - synchronized 只有非公平锁策略,而 Lock 既可以是公平锁也可以是非公平锁(RentrantLock 默认是非公平锁,也可以通过构造函数设置 true 声明它为公平锁)
- ReentrantLock 更加灵活(比如 try Lock)
- synchronized 是自动加锁和释放锁的,而 ReentrantLock 需要自己手动加锁和释放锁
以上是关于面试必问系列 --- 多线程安全问题的主要内容,如果未能解决你的问题,请参考以下文章