线程安全
1. 线程不安全的原因
-
修改共享数据
一般变量都是在堆上存储, 多个线程可以共享访问,如果多个线程同时修改就可能造成数据错误. -
原子性
我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的。
一条 java 语句不一定是原子的,也不一定只是一条指令,例如我们常用的 i++,其实是由三步操作组成的:- 从内存把数据读到 CPU
- 进行数据更新
- 把数据写回到 CPU
如果当前线程执行到一半CPU切换到另外一个线程就会出现问题
如上图所示线程1 读取到 0 然后切换到线程2 此时线程2 读到的也是 0, 线程2 执行完之后 i = 1, 切换回线程1 之后因为线程1 读到的是 0 所以还是会把 i 赋值为 1, 相当于两次++ 但是 i 只增加了1 ,此时就会出现问题.
-
可见性
可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到. -
指令重排序
如果是在单线程情况下,JVM、CPU指令集会对其进行优化, 这种叫做指令重排序
编译器对于指令重排序的前提是 “保持逻辑不发生变化”. 这一点在单线程环境下比较容易判断, 但是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价.
2. synchronized
2.1 synchronized 特性
-
互斥
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待.
进入 synchronized 修饰的代码块, 相当于 加锁
退出 synchronized 修饰的代码块, 相当于 解锁
可以把这个行为想象成上公共场所当一个人进入厕所时会把门锁上, 后边的人想要上厕所就需要排队等待.
synchronized的底层是使用操作系统的mutex lock实现的. -
内存可见性
synchronized 的工作过程:- 获得互斥锁
- 从主内存拷贝变量的最新副本到工作的内存
- 执行代码
- 将更改后的共享变量的值刷新到主内存
- 释放互斥锁
所以 synchronized 也能保证内存可见性.
-
可重入性
synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题.
在可重入锁的内部, 包含了 “线程持有者” 和 “计数器” 两个信息.
如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增.
解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)
2.2 synchronized 的使用场景
- 直接修饰普通方法:
public class SynchronizedDemo {
public synchronized void methond() {
}
}
- 修饰静态方法:
public class SynchronizedDemo {
public synchronized static void method() {
}
}
- 修饰代码块:
public class SynchronizedDemo {
public void method() {
synchronized (this) {
}
}
}
3. volatile
volatile 修饰的变量, 能够保证 “内存可见性”.
我们来看下面一段代码:
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (test.count == 0) {
}
System.out.println("t1结束");
});
Thread t2 = new Thread(() -> {
Scanner sc = new Scanner(System.in);
System.out.println("请输入count");
test.count = sc.nextInt();
});
t1.start();
t2.start();
}
按照我们预想的在输入一个非零的值时 t1 就会结束, 然而实际结果却是这样:
很多人看的这里就懵了😵,明明count 的值已经修改为什么 t1 还没有结束?
这就是今天我们要提到的内存可见性问题,.
每一个线程都有自己的工作内存, 当需要读取变量时就会访问该内存, 当工作内存多次访问主存发现变量并未修改, 这时就会产生编译器优化, 不在访问主存, 所以 t1 这个线程访问到的 count 的值一直就是 0 就导致线程不会停止.
解决这个问题的方法就是在变量前 加上 volatile 这个关键字, 让编译器不在优化,强制读写内存.
这个时候我们在来看一下加上 volatile 之后程序运行的效果:
由此可见, 当我们输入一个非零的值时 t1 这个线程就会从内存中读取到 count 的变化从而结束线程.
注意: volatile 不能保证原子性
4. wait 和 notify
4.1 wait
wait 做的事情:
使当前执行代码的线程进行等待. (把线程放到等待队列中)
释放当前的锁
满足一定条件时被唤醒, 重新尝试获取这个锁.
wait 要搭配 synchronized 来使用. 脱离 synchronized 使用 wait 会直接抛出异常.
wait 结束等待的条件:
其他线程调用该对象的 notify 方法.
wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间).
其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常.
4.2 notify
notify 方法是唤醒等待的线程.
方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。
如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程。(并没有 “先来后到”)
在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁
5. 总结-保证线程安全的思路
- 使用没有共享资源的模型
- 适用共享资源只读,不写的模型
- 不需要写共享资源的模型
- 使用不可变对象
- 直面线程安全
- 保证原子性
- 保证顺序性
- 保证可见性