AI摘要:本文通过两个示例程序,探讨了JavaEE中的线程安全问题。第一个示例展示了多线程购票程序中,由于线程不安全导致的问题。第二个示例则说明了线程可见性问题,即一个线程对共享变量的修改不能及时被其他线程看到。文章解释了线程安全的基本概念,包括原子性、可见性和有序性,并分析了线程不安全的原因。最后,文章提出了解决线程不安全问题的三种方法:使用synchronized关键字、lock手动锁和volatile关键字。详细解释了synchronized关键字的工作原理、互斥性、刷新内存和可重入性。同时,也介绍了volatile关键字的作用,即保证可见性和有序性,但不能保证原子性。通过这些方法,可以确保多线程环境下程序的正确执行。
Powered by AISummary.
线程安全问题
一. 线程不安全问题举例
1. 多线程购票程序示例
下面是一个简单的多线程购票程序,使用两个线程模拟两个用户进行购票的操作,我们来看一下结果如何。
♾️ java 代码:public class TicketSellingDemo {
private static int ticketCount = 1;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (ticketCount < 100) {
System.out.println(Thread.currentThread().getName() + "正在卖第:" + ticketCount++ + "张票");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
Thread t2 = new Thread(() -> {
while (ticketCount < 100) {
System.out.println(Thread.currentThread().getName() + "正在卖第:" + ticketCount++ + "张票");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
t1.setName("窗口1");
t2.setName("窗口2");
t1.start();
t2.start();
}
}
结果是两个窗口卖出了同一张票,这并不符合预期,因为两个线程同时操作一个共享变量时,会涉及线程安全问题。
2. 线程可见性问题示例
让我们来看另一个示例:
♾️ java 代码:public class VisibilityDemo {
private static int isQuit = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (isQuit == 0) {
// 循环体啥也没干
// 意味着执行速度极快,一秒钟会执行很多次
}
System.out.println("t线程执行完毕");
});
t1.start();
Scanner sc = new Scanner(System.in);
isQuit = sc.nextInt();
System.out.println("main线程执行完毕");
}
}
结果是:flag 的值已经不为0,但是t线程还没有执行结束,因为t线程读取flag时读的是寄存器中的0。
那么为什么会出现上面的问题呢?我们来继续分析。
二. 线程安全
在多线程环境下,如果代码运行的结果与在单线程环境下的预期结果一致,那么我们可以说这个程序是线程安全的。线程安全问题通常涉及对共享变量的并发访问。
- 如果所有操作都是读操作,也就是不修改变量的值,通常不存在线程安全问题。
- 如果至少有一个操作是写操作,那么就可能存在线程安全问题。
三. 线程不安全的原因
要考虑线程安全问题,就需要先考虑 Java 并发的三大基本特性:原子性、可见性以及有序性。
1. 原子性
原子性指的是一组操作(一行或多行代码)是不可拆分的最小执行单位,这表示这组操作是具有原子性的。
当多个线程并发并行地对一个共享变量进行操作时,如果这些操作不是原子性的,就会导致线程不安全。
- 我们对第一个例子进行分析:
这最终导致的结果是一张票被售卖了两次,这样就具有很大的风险性。
- 再举个典型的例子:n++ 和 n-- 操作。
经过一次 n++ 和 n-- 操作后,结果并不等于预期的0,而是随机等于1或-1。
2. 可见性
可见性指的是一个线程对共享变量值的修改能够及时被其他线程看到。多个线程在工作时都会在自己的工作内存中(CPU 寄存器)执行操作,线程之间不可见。
- 共享变量存在于主内存中。
- 每个线程都有自己的工作内存。
- 线程读取共享变量时,会先将变量从主内存拷贝到自己的工作内存(寄存器),然后从工作内存(寄存器)中读取数据。
- 线程修改共享变量时,会先修改自己工作内存中的变量值,然后将修改同步到主内存。
我们来对一开始举的第二个例子进行分析:
3. 有序性
一段代码是这样的:
- 去前台取下 U 盘
- 去教室写 10 分钟作业
- 去前台取下快递
如果是在单线程情况下,JVM、CPU 指令集会对其进行优化,比如,按 1->3->2 的方式执行,也是没问题的,可以少跑一次前台。这种叫
做指令重排序。
程序执行的顺序按照代码的先后顺序执行,在多线程编程时就得考虑这个问题。
四. 解决线程不安全问题
解决办法:使用多线程之间使用 关键字 synchronized、或者使用 锁(lock),或者 volatile 关键字。
① synchronized(自动锁,锁的创建和释放都是自动的);
② lock 手动锁(手动指定锁的创建和释放)。
③ volatile 关键字
4.1 synchronized 关键字
直接修饰普通方法
♾️ java 代码:public class SynchronizedDemo { public synchronized void method() { // 同步代码块 } }
修饰静态方法
♾️ java 代码:public class SynchronizedDemo { public synchronized static void method() { // 同步代码块 } }
修饰代码块
♾️ java 代码:public class SynchronizedDemo { public void method() { synchronized (this) { // 同步代码块 } } }
锁类对象
♾️ java 代码:public class SynchronizedDemo { public void method() { synchronized (SynchronizedDemo.class) { // 同步代码块 } } }
- synchronized 是基于对象头加锁的,特别注意:不是对代码加锁,所说的加锁操作就是给这个对象的对象头里设置了一个标志位。
- 一个对象在同一时间只能有一个线程获取到该对象的锁。
- synchronized 保证了原子性,可见性,有序性(这里的有序不是指指令重排序,而是具有相同锁的代码块按照获取锁的顺序执行)。
互斥
synchronized 会起到互斥效果,某个线程执行到某个对象的 synchronized 中时,其他线程如果也执行到同一个对象的 synchronized 就会阻塞等待。
进入 synchronized 修饰的代码块,相当于加锁,
退出 synchronized 修饰的代码块,相当于解锁。
可以粗略理解为,每个对象在内存中存储的时候,都存有一块内存表示当前的 "锁定" 状态(类似于厕所的 "有人/无人")。如果当前是 "无人" 状态,那么就可以使用,使用时需要设为 "有人" 状态。如果当前是 "有人" 状态,那么其他人无法使用,只能排队。
刷新内存
synchronized 的工作过程:
- 获得互斥锁
- 从主存拷贝最新的变量到工作内存
- 对变量执行操作
- 将修改后的共享变量的值刷新到主存
- 释放互斥锁
所以 synchronized 也能保证内存可见性.
可重入
synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。
可重入锁内部会记录当前的锁被哪个线程占用,同时也会记录一个加“锁次数”,对于第一次加锁,记录当前申请锁的线程并且次数加一,但是后续该线程继续申请加锁的时候,并不会真正加锁,而是将记录的“加锁次数加1”,后续释放锁的时候,次数减1,直到次数减为0才是真的释放锁。
可重入锁的意义就是降低程序员负担(使用成本来提高开发效率),代价就是程序的开销增大(维护锁属于哪个线程,并且加减计数,降低了运行效率)。
2. volatile 关键字
- volatile 用来修饰变量,它的作用是保证可见性,有序性。
注意:不能保证原子性,对 n++、n-- 来说,用 volatile 修饰 n 也是线程不安全的。 volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见
性.
我们使用 volatile 关键字来修改第一个示例代码来看一下效果。
♾️ java 代码:public class VisibilityDemo {
private static volatile int isQuit = 0;
public static void main(String[] args) {
Thread t = new Thread(() -> {
while (isQuit == 0) {
// 循环体啥也没干
// 意味着执行速度极快,一秒钟会执行很多次
}
System.out.println("t线程执行完毕");
});
t.start();
Scanner sc = new Scanner(System.in);
isQuit = sc.nextInt();
System.out.println("main线程执行完毕");
}
}
前面我们讨论内存可见性时说了, 直接访问工作内存(实际是 CPU 的寄存器或者 CPU 的缓存), 速度
非常快, 但是可能出现数据不一致的情况.
加上 volatile , 强制读写内存. 速度是慢了, 但是数据变的更准确了.
代码在写入 volatile 修饰的变量的时候
- 改变线程工作内存中 volatile 变量副本的值
- 将改变后的副本的值从工作内存刷新到主内存
代码在读取 volatile 修饰的变量的时候
- 从主内存中读取 volatile 变量的最新值到线程的工作内存中
- 从工作内存中读取 volatile 变量的副本