当我们使用多个线程访问同一资源(可以是同一变量, 同一个文件, 同一条记录等) 的时候, 若多个过程只有读操作, 那么不会发生线程安全问题. 但是如果多个线程中对资源有读和写的操作, 就容易出现线程安全问题.
class Counter {
public int count = 0;
public void increase() {
count+=1;
}
}
public class Demo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread thread2 = new Thread(()-> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(counter.count);
}
}
我们认为count应该是100000, 但是实际输出的却不是, 这就说明上述代码有线程安全问题.
线程的调度不是按顺序的, 而是抢占式的, 这是系统规定, 我们无法修改.
上面线程不安全的代码中, 涉及多个线程对counter.count
变量进行修改, 此时这个counter.count
是一个多线程都能访问到的"共享数据".
counter.count
这个变量就在堆上, 因此可以被多个线程共享访问.
什么是原子性?
我们把一段代码想象成一个房间, 每个线程就是要进入这个房间的人. 如果没有任何机制保证, A进入房间之后, 还没有出来; B 是不是也可以进入房间, 打断 A 在房间里的隐私. 这个就是不具备原子性的.
那我们应该如何解决这个问题呢? 是不是只要给房间加一把锁, A 进去就把门锁上, 其他人是不是就进不来了. 这样就保证了这段代码的原子性了.
有时也把这个现象叫做同步互斥, 表示操作是互相排斥的.
一条Java语句不一定是原子的, 也不一定只是一条指令.
比如上述代码中的count+=1
其实是三个操作组成的:
时间 | t1 | t2 |
---|---|---|
T1 | load | |
T2 | load | |
T3 | add | |
T4 | save | |
T5 | add | |
T6 | save |
不保证原子性会带来的问题: 如果一个线程正在对一个变量操作, 中途其他线程插入进来了, 如果这个操作被打断了, 结果就可能是错误的.
这点也和线程的抢占式调度密切相关. 如果线程不是 “抢占” 的, 就算没有原子性, 也问题不大.
可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到.
我们先来看下面的代码
public class Demo {
private static int isQuit = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (isQuit == 0) {
;
}
System.out.println("t1 end");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("其输入isQuit的值");
isQuit = scanner.nextInt();
});
t1.start();
t2.start();
}
}
我们先输入1, 结果线程t1还未停止.
我们想让线程t1在
isQuit
非零时停止, 但事实并非所愿, 这就是内存可见性引发的线程安全问题.
程序在编译运行的时候, Java编译器和JVM可能会对代码作出一些"优化", 在保持原有逻辑不变的情况下, 提高代码的执行效率, 这就称为 编译器优化.
编译器优化本质是靠代码智能地对代码进行分析判断, 进行调整. 这个调整过程大部分情况下都能保持逻辑不变, 但是如果遇到多线程, 就可能会发生差错, 逻辑改变.
while (isQuit == 0)
本质上是两个指令: 一是读内存; 二是比较并跳转
比较操作是在寄存器上进行的, 速度十分快, 相较之下, 读内存操作就会显得很慢.
此时, JVM就会反应到, 这个代码要反复读取同一个内存值, 读出的结果还都是一样的, 于是编译器就直接把读内存这个指令给优化掉了, 只读一次内存, 后续直接拿寄存器中的数据比较, 大大加快了执行速度.
但是, JVM没有预料到我们会在其他线程修改
isQuit
的值, 编译器没法准确判定出t2线程会不会执行, 什么时候执行, 因此就出现了误判.虽然其他线程把值修改了, 但是另一个线程中没有重复读取
isQuit
的值, 这就引发了内存可见性的问题.
指令重排序也是编译器优化的一种手段, 在保证原有逻辑不发生变化的情况下, 对代码执行的顺序进行调整, 使调整后的执行效率变高.
编译器对于指令重排序的前提是 “保持逻辑不发生变化”. 这一点在单线程环境下比较容易判断, 但是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价.
线程安全问题的原因:
为了保证每个线程都能正常执行原子操作, Java引入了线程同步机制. 注意: 在任何时候, 最多允许一个线程拥有同步锁, 谁拿到锁就进入代码块, 其他的线程只能在外等着( BLOCKED) .
同步机制的原理, 其实就相当于给某段代码加“锁”, 任何线程想要执行这段代码, 都要先获得“锁”, 我们称它为同步锁.
同步代码块: synchronized 关键字可以用于某个区块前面, 表示只对这个区块的资源实行互斥访问.
synchronized(加锁的对象){
//需要同步操作的代码
}
public void increase() {
synchronized(this) {
count+=1;
}
}
同步方法: synchronized 关键字直接修饰方法, 表示同一时刻只有一个线程能进入这个方法, 其他线程在外面等着.
public synchronized void method(){
//可能会产生线程安全问题的代码
}
public synchronized void increase() {
count+=1;
}
synchronized 进行加锁解锁, 是以对象为维度进行的. 使用synchronized 的时候, 其实是指定了某个具体对象进行加锁.
对于同步代码块来说, 同步锁对象是由程序员手动指定的 ; 但是对于同步方法来说, 同步锁对象只能是默认的:
静态方法: 默认加锁对象是当前类的Class对象(类名.class)
public synchronized static void method(){
//可能会产生线程安全问题的代码
}
//相当于
public static void method(){
synchronized(类名.class) {
//可能会产生线程安全问题的代码
}
}
非静态方法: 默认加锁对象是this
如果多个线程对同一个对象进行加锁, 就会出现锁竞争; 如果是多个对象针对不同的对象进行加锁, 不会产生锁竞争.
class Counter {
public int count = 0;
public void increase() {
synchronized(this) {//对this加锁
count+=1;
}
}
}
public class Demo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread thread2 = new Thread(()-> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
// 两个线程对同一个对象(this -> counter)加锁, 那么结果就是100000
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(counter.count);
}
}
class Counter {
public int count = 0;
private Object locker = new Object();
public void increase() {
synchronized(this) {//对this加锁
count+=1;
}
}
public void increase2() {
synchronized(locker) {//对locker加锁
count+=1;
}
}
}
public class Demo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread thread2 = new Thread(()-> {
for (int i = 0; i < 50000; i++) {
counter.increase2();
}
});
// 两个线程对不同对象(this -> counter / locker)加锁, 那么结果就不是100000.
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(counter.count);
}
}
class Counter {
public int count = 0;
private Object locker = new Object();
public void increase() {
synchronized(locker) {//对locker加锁
count+=1;
}
}
public void increase2() {
synchronized(locker) {//对locker加锁
count+=1;
}
}
}
public class Demo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread thread2 = new Thread(()-> {
for (int i = 0; i < 50000; i++) {
counter.increase2();
}
});
// 两个线程对同一对象(locker)加锁, 那么结果就是100000.
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(counter.count);
}
}
总结:
同步锁对象可以是任意类型, 但是必须保证 竞争"同一个共享资源"的多个线程必须针对同一个对象进行加锁。
互斥
synchronized 会起到互斥效果, 某个线程执行到 synchronized 修饰的代码块中时, 其他线程如果也执行到同一个对象的此代码块, 就会阻塞等待.
可以粗略理解成, 每个对象在内存中存储的时候, 都存有一块内存表示当前的 “锁定” 状态(类似于厕所的 “有人/无人”).
如果当前是 “无人” 状态, 那么就可以使用, 使用时需要设为 “有人” 状态.
如果当前是 “有人” 状态, 那么其他人无法使用, 只能排队
理解"阻塞等待"
针对每一把锁, 操作系统内部都维护了一个等待队列, 当这个索贝某个线程栈有的时候, 其他线程尝试进行加锁, 就加不上了, 会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的线程, 再来获取到这个锁.
注意:
- 上一个线程解锁之后, 下一个线程并不会立即就能获得到锁, 而是要靠操作系统来"唤醒", 这也是操作系统调度的一部分工作.
- 假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则.
刷新内存
synchronized 的工作过程:
volatile 修饰的变量, 编译器就不会把读操作优化到都寄存器中, 于是就能保证在循环过程中, 始终能读取内存中的数据, 保证 “内存可见性”.
代码在 写入 volatile修饰的变量的时候:
代码在 读取 volatile 修饰的变量的时候:
直接访问工作内存(实际是 CPU 的寄存器或者 CPU 的缓存), 速度非常快, 但是可能出现数据不一致的情况.
加上volatile, 强制读写内存, 速度虽然慢了, 但是数据更准确了.
代码示例
在这个代码中
创建两个线程 t1 和 t2
t1 中包含一个循环, 这个循环以 flag == 0 为循环条件.
t2 中从键盘读入一个整数, 并把这个整数赋值给 flag.
预期当用户输入非 0 的值的时候, t1 线程结束.
static class Counter {
public int flag = 0;
}
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
while (counter.flag == 0) {
// do nothing
}
System.out.println("循环结束!");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("输入一个整数:");
counter.flag = scanner.nextInt();
});
t1.start();
t2.start();
}
// 执行效果
// 当用户输入非0值时, t1 线程循环不会结束. (这显然是一个 bug)
t1读的是自己工作内存中的数据
当t2对flag变量进行修改, 此时t1感知不到flag的变化
如果给 flag 加上 volatile
static class Counter {
public volatile int flag = 0;
}
// 执行效果
// 当用户输入非0值时, t1 线程循环能够立即结束.
volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见性.
代码示例
static class Counter {
volatile public int count = 0;
void increase() {
count++;
}
}
public static void main(String[] args) throws InterruptedException {
final Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
此时可以看到, 最终 count 的值仍然无法保证是 100000.