文章目录
- 前言
- 产生线程不安全的原因
- 线程不安全的解决办法
- [synchronized 与 volatile 对比](#synchronized 与 volatile 对比)
前言
线程的安全主要指的是由于操作系统进行线程调度的时候是否会带来问题
一个经典的线程不安全的例子是两个线程分别自增对应的数字,如下所示,两个线程总共相加应该为100000的情况,但最终得到的结果总在50000和100000之间
java
class Counter{
public int count;
public void increase(){
count++;
}
}
public class Demo {
private static Counter counter = new Counter();
private static int count2 = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for(int i = 0;i<50000;i++){
counter.increase();
count2++;
}
});
Thread t2 = new Thread(()->{
for(int i = 0;i<50000;i++){
counter.increase();
count2++;
}
});
t1.start();
t2.start();
//在t1、t2执行完之后再打印count的结果
//在main中打印两个线程执行的结果
t1.join();
t2.join();
System.out.println(counter.count);//53090
System.out.println(count2);//52993
}
}
count在进行自增时候,首先要内存中的 count 值加载到CPU寄存器中,在寄存器中的值加一,最后把寄存器的值写回到内存的count中。因此在"抢占式执行"的时候,两个操作导致本来应该自增两次,结果只自增了一次
可以通过在自增之前加锁,自增后解锁。加锁后并发程度就降低了,此时的数据操作更具有安全性,但也会导致执行速度变慢。加锁最常使用synchronized,给方法直接加synchronized关键字,此时进入方法会自动加锁,离开方法自动解锁。线程加锁成功后,其他线程尝试加锁会触发阻塞等待,此时的线程就处在 BLOCKED 阻塞状态,阻塞会一致持续到占用锁的线程把锁释放位置。
java
class Counter{
public int count;
synchronized public void increase(){
count++;
}
}
产生线程不安全的原因
- 线程抢占式执行,线程之间的调度随机
- 多个线程对同一个变量进行修改,针对两个变量的情况不会产生问题,或多个线程针对同一个变量进行读操作时也不会产生问题
- 针对变量的操作不是原子性的,例如针对读取变量的值,该操作只对应一条机器语言,因此可以认为是原子的,对非原子的操作可以通过加锁把多个指令打包成一个原子的
- 内存的可见性也会影响到线程的安全。即一个t1线程在循环读,一个t2线程进行写操作,由于 t1 线程读取内存的效率比读取寄存器的效率要低很多(3-4个数量级),于是编译器自我优化,此时t1就直接从寄存器中读取数据,t2 再进行修改的后,t1 就会读取到错误的数据,如下例所示,线程 t 没有感知到数据的变化
java
import java.util.Scanner;
public class Demo {
private static int isQuit = 0;
public static void main(String[] args) {
Thread t =new Thread(()->{
while(isQuit == 0){
//循环读取isQuit的值
}
System.out.println("循环结束!t 线程退出!");
});
t.start();
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个isQuit值:");//输入修改后t线程并没有执行结束
isQuit = scanner.nextInt();
System.out.println("main线程执行完毕");
}
}
- 指令重排序,也是编译器优化中的一种操作,即编译器在不改变逻辑的前提下,改变代码执行的先后顺序,需要使用synchronized关键字,同时保证原子性、内存可见性、禁止指令重排序。
线程不安全的解决办法
- 使用synchronized关键字: synchronized可以保证指令的原子性
- 使用volatile关键字,和原子性无关但能保证内存可见性,会禁止编译器进行优化,此时编译器每次执行判定会重新读取内存的值,如下所示
死锁
(1)死锁产生的情况
- 一个线程,同时加多个锁,相互等待
- 两个线程,两个锁,互相等待
- N个线程M个锁(哲学家就餐问题)
(2)死锁的四个必要条件 - 互斥使用:进程之间互斥使用资源,任意时刻一个资源只能给一个进程使用,即一个锁被一个线程占用后其他线程占用不了;
- 不可抢占:进程所获得的资源在未使用完毕之前,不被其他进程强行剥夺,而只能由获得该资源的进程资源释放,即一个锁被线程占用后,其他线程只能等待其释放,不能进行抢占;
- 请求和保持:进程在申请新资源时,继续占用已分配到的资源,即线程占据了多把锁后,除非显示的释放锁,否则会一直持有;
- 环路等待:每个线程都在等待下一个线程持有的资源,从而形成一个锁。
synchronized关键字
使用方法
- 修饰普通的方法
本质上是在对某个" 对象 "进行加锁,锁对象是当前实例(this)
java
class Counter{
public int count;
synchronized public void increase(){
count++;
}
}
- 修饰一个代码块
需要显示的指定那个对象加锁,在JAVA中,任意对象都可以作为一个锁对象,这也是JAVA语言的一个特色
java
class Counter{
public int count;
public void increase(){
synchronized(this){
count++;
}
}
}
- 修饰一个静态方法
静态方法本质上来说是一个类方法,因此相当于给当前的类对象加锁
java
class Counter{
public int count;
public static void func(){
synchronized(Counter.class){
}
}
}
java
class Counter{
public int count;
synchronized public static void func(){
}
}
volatile关键字
volatile关键字只能修饰变量,其作用如下,可结合JMM理解进行学习,参考链接
- 保证可见性:写 volatile 变量会立即刷新到主内存,读 volatile 变量会从主内存重新加载。
- 禁止指令重排序:通过内存屏障(LoadLoad、StoreStore 等)防止编译器和 CPU 重排。
- 不保证原子性:volatile int count; count++ 仍然不安全。
典型使用场景为双重检查锁(DCL)单例模式,防止指令重排序导致返回未完全初始化的对象
java
public class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // volatile 禁止重排
}
}
}
return instance;
}
}
synchronized 与 volatile 对比
| 特性 | synchronized | volatile |
|---|---|---|
| 原子性 | 保证 | 不保证(复合操作不安全) |
| 可见性 | 保证 | 保证 |
| 有序性 | 临界区内相对有序 | 禁止重排序 |
| 作用对象 | 修饰方法或代码块 | 只能修饰变量 |
| 锁机制 | 互斥锁,可能阻塞 | 无锁,基于内存屏障 |
| 性能 | 较重(有锁竞争时) | 轻量(无阻塞) |
| 适用场景 | 需要原子性操作的复合操作、互斥访问共享资源 | 一写多读的状态标志位、双重检查锁单例 |