多线程安全问题
- 线程安全与不安全
-
- 安全线程
- 不安全线程
- 线程执行方式
- 线程不安全原因
-
- 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步:
- load 读取操作:jvm中有一块区域叫作主内存,从中读取count的值
- calc 运算操作:读取到了count值后,进行运算操作,对应代码的 ++ 和 –
- save保存操作,把运算好的count值再保存回主内存中,一次循环操作才结束
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了,但是代码并不会终止,一直在运行
这是由于线程的不可见性导致线程不安全出现的情况。
文章图片
线程的工作方式:
- 先去自己的工作内存中找变量
- 去主内存里面找变量
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作用:
- 禁止指令重排序
- 解决线程可见性问题
实现原理:当操作完变量之后,强制删除掉工作内存中的变量
线程安全通用解决方案
- CPU抢占调度(不能)
- 每个线程操作自己的变量(可能行):不通用,修改难度大
- 在关键代码上让所有的 CPU 排队执行,加锁。
- 尝试获取(如果成功拿到锁加锁,否则排队等待)
- 释放锁
实现原理
操作层面:
- synchronized的底层是使用操作系统的互斥锁mutex lock实现的。
- 当线程释放锁时,JMM会把该线程对应的工作内存中的共享变量刷新到主内存中
- 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码 必须从主内存中读取共享变量
- 锁对象 mutex
- 锁存放的地方:变量的对象头
synchronized用的锁是存在Java对象头里的
文章图片
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 既可以是公平锁也可以是非公平锁(RentrantLock 默认是非公平锁,也可以通过构造函数设置 true 声明它为公平锁)
- ReentrantLock 更加灵活(比如 try Lock)
- synchronized 是自动加锁和释放锁的,而 ReentrantLock 需要自己手动加锁和释放锁
推荐阅读
- 面试|线程安全系列面试问题进阶
- 操作系统|面试必考 | 进程和线程的区别
- 多线程|wait和notify实现线程之间的通信
- 多线程|Java多线程基础(线程与进程的区别,线程的创建方式及常用api,线程的状态)
- 多线程|单例模式中的线程安全问题
- 数据结构|基本排序算法总结(Java实现)
- 如何写出高性能代码(二)巧用数据特性
- java|ElasticSearch系列(七)es内存大小设置
- Java与开发|FAQ智能问答系统设计与实现