线程安全
一、线程不安全
如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。否则就称之为线程不安全。
线程不安全的原因:
二、线程不安全案例与解决方案
1、修改共享资源
例如如下代码:
class Counter {
private int count = 0;
public void add() {
count++;
}
public int getCount() {
return count;
}
}
public class ThreadExample_unsafe {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
t1.start();
t2.start();
//等两个线程结束后查看结果
t1.join();
t2.join();
System.out.println(counter.getCount());
}
}
结果分析:
对于如上代码,两个线程 t1、t2 各自对 count 自增 50000 次,理论情况下结果应为100000,但是实际运行结果小于100000,尽管多次运行依旧如此。以上现象正是因为,在 t1、t2 两个线程修改 count 时,由于每个 ++ 操作都不是原子的,可以分割为(1.读取 2.修改 3.写入),在系统随机调度的加持下就会导致 t1、t2 线程++操作实际指令排列顺序有多种可能,最终导致结果异常。如下图绘制了两种可能出现的情况:
解决方案-加锁
对于以上场景,在保证并发执行的情况下,由于线程的随机调度是系统内核来实现的,程序员不可控,而多个线程修改同一变量又是业务需求,所以要保证该场景下的线程安全我们可以考虑将修改操作变成原子的。而“加锁”可以保证原子性效果。synchronized
是 Java 中用于实现锁的关键字,下面我们详细介绍:
synchronized 使用
Java中使用synchronized
针对“对象头”
加锁,synchronized 势必要搭配一个具体的对象来使用。
(1)synchronized对普通方法加锁
// 给实例方法加锁
public void add() {
synchronized (this) {
count++;
}
}
//如果直接给方法使用synchronized修饰,此时就相当于以this为锁对象
synchronized public void add() {
count++;
}
(2)synchronized对静态方法加锁
//给静态方法加锁
public static void test2() {
// Counter.class相当于类对象
synchronized (Counter.class) {
}
}
//如果直接给方法使用synchronized修饰,此时就相当于以Counter.class为锁对象
synchronized public static void test() {
}
(3)synchronized对任意代码块加锁
// 自定义锁对象
Object locker = new Object();
synchronized (locker) {
// 代码逻辑
// . . .
}
拓展:被 synchronized
修饰的方法又叫同步方法;被 synchronized
修饰的代码块又叫同步代码块。
synchronized 特性
2、内存可见性
Java内存模型(JMM)
介绍内存可见性之前,我们先简单了解一下java内存模型:
- 工作内存-work memory :CPU寄存器 + 缓存
- 主内存-main memory :内存
为什么引入工作内存?
内存可见性问题
什么是内存可见性引起的多线程安全问题?
一般来说由内存可见性引发的多线程问题,是由于编译器的优化。例如:
public class ThreadExample_unsafe2 {
public static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while (flag == 0) {
//空转
}
System.out.println("循环结束,t1结束!");
});
Thread t2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.print("请输入一个整数:");
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
结果分析
如上代码,t1线程中flag == 0涉及到两个CPU指令,假设这两个指令分别是load-从内存读取数据到工作内存(CPU寄存器),cmp-比较寄存器中的值是否为0。对于这两个操作,load的时间开销远远高于cmp。此时编译器在处理的时候发现,load的开销很大,每次load的结果都一样,此时编译器就做了一个非常大胆的决定,即只有第一次load执行从内存读取到工作内存,后续循环的load直接从工作内存读取。所以尽管输入了不为0的整数,因为工作内存数据不变,程序依然继续运行。
关于编译器优化:
解决方案
3、指令重排列
例如下面伪代码:
其中线程t1中s = new Student();
大体可以分为3步:
如果是单线程下,上述操作很容易保证,如果是多线程下,指令2,3重排先执行3后执行2,在刚执行完指令3后,t2线程执行s.learn();就会出现bug。
解决方案
4、synchronized 和 volatile
5、拓展知识:修饰符顺序规范
在Java中,修饰符的顺序可以任意排列,但是为了方便阅读和代码的一致性,一般会按照以下的顺序进行排列: