一、引言
这一期来和大家说一下我们非常重要的线程安全问题,提到多线程怎么线程安全是一定绕不开一个话题我们在写多线程代码的过程当中经常会不小心就出现线程安全问题,这是非常危险的问题,那么这一期就来和大家讲解一下线程安全问题以及怎么去处理问题。
二、为什么线程会不安全
在聊这个之前先来看一段代码
java
public class Test12 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for(int i=0;i<50000;i++){
count++;
}
});
Thread t2 = new Thread(()->{
for(int i=0;i<50000;i++){
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
上面是一段基础的多线程测试代码。两个线程各自循环执行 5万次 count++ 操作,理论上最终结果应当为 100000,但实际运行后会发现:输出结果始终小于预期值,且每次运行结果都不相同,数值大多落在 50000 ~ 100000之间。出现这种现象,核心原因是多线程并发执行时,count++ 操作不具备线程安全特性,再加上操作系统对线程的随机调度。下面我们逐层拆解背后的原理。
在 Java 内存模型中,共享变量 count 存放在主内存中。每个线程都会拥有独立的工作内存,线程无法直接操作主内存变量,完整的读写修改分为三步:
- 从主内存中读取
count,拷贝到当前线程的工作内存; - 在工作内存中执行
count++自增操作; - 将修改后的值刷新回主内存。
线程 t1、t2 都会遵循这套流程操作共享变量,并发问题也就由此产生。由于线程调度顺序是随机的,很容易出现执行序列交错的情况:比如 t1 先把主内存的 count 读取到自身工作内存,还未完成刷新;此时 t2 也读取了同一时刻的 count 值。两个线程分别完成自增后,又先后将结果写回主内存。
这种情况下,两次 count++ 操作最终只让主内存数值 +1,相当于一次操作被覆盖丢失。在数十万次循环中,这类冲突会反复出现,最终导致统计结果远小于理论值。
该问题的本质,是代码操作不具备原子性 。我们可以用通俗的例子理解原子性:把一段临界代码比作一间房间,线程就是进出房间的人。若无任何约束,线程 A 进入房间执行操作、尚未离开时,线程 B 也能同时闯入,打乱执行逻辑,这就是典型的非原子性。
想要保证操作原子性,就需要为这段代码 "加锁":一个线程进入执行时立刻锁住资源,其他线程只能等待,直到当前线程执行完毕、释放锁之后才能进入。通过这种同步互斥的机制,就能保证代码片段整体不可被打断,彻底解决并发冲突。
Java 并发编程中,三大核心特性分别是原子性、可见性、有序性 ,前面我们讲到了原子性与可见性。 其中可见性指:当一个线程修改了共享变量的值后,该修改结果能否被其他线程及时感知并读取到最新数据。
最后再来讲解指令重排序 ,它对应并发里的有序性 问题。 为了提升程序运行效率,编译器、CPU 在不改变代码最终执行结果的前提下,会对指令的执行顺序进行重新排列,这个行为就叫做指令重排序。
也就是说我们要给线程t1加上一把锁把他锁起来这样就可以保证t1先对count执行完操作后t2再执行,这里就涉及到了加锁的操作,而再Java里面加锁是用synchronized关键字来实现的,下面就来讲解一下解决方法和synchronized。
三、synchronized
synchronized 关键字本质上就是实现线程锁机制。当一个线程获取锁后,其他线程想要获取同一把锁时就会被阻塞,直到当前线程释放锁为止。在 Java 中,进入 synchronized 代码块就相当于加锁,离开代码块时自动解锁。Java 没有专门的方法来显式解锁,这个机制是自动完成的。
举个生活中的例子:就像试衣间使用场景 - 当一个人进入试衣间并锁上门后,其他人必须等待里面的人出来(释放锁)才能进入。线程同步也是同样的原理:一个线程先获得锁,其他线程必须等待该线程释放锁后才能获取。
java
public class Test12 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(()->{
for(int i=0;i<50000;i++){
synchronized (locker){
count++;
}
}
});
Thread t2 = new Thread(()->{
for(int i=0;i<50000;i++){
synchronized (locker){
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
在上面的代码里面我们是用Object类实例化了一把锁去使用当然使用其他的类也是可以的这里更推荐使用Object类去实例化锁。在这里如果我们在实例化一把锁,并且让t2用另一把锁上锁的话就还是会发生线程安全问题,必须用同一把锁才可以解决。
3.1 synchronized的特性
讲完了上面的东西就来说一下synchronized的特性。
首先是第一个互斥性,synchronized会起到互斥的效果某个线程执⾏到某个对象的 synchronized 中时, 其他线程如果也执⾏到同⼀个对象 synchronized 就会阻塞等待。
第二个是可重入性,这里涉及到一个死锁的问题比如说写了这样的代码
java
Thread t1 = new Thread(()->{
for(int i=0;i<50000;i++){
synchronized (locker){
synchronized (locker){
count++;
}
}
}
});
当一个线程持有某个锁时,如果在该锁的代码块内再次尝试获取同一把锁,正常情况下会导致死锁------因为前一个锁尚未释放就试图获取它,线程会陷入阻塞。但Java的synchronized属于可重入锁,支持在同一个锁的代码块中重复获取锁,因此不会出现这种问题。实际上,编译器会自动优化这种无意义的重复加锁操作,最终只保留一个锁。
那么Java中是否完全不会发生死锁呢?并非如此。假设存在两把锁和两个线程:第一个线程先获取锁A,在持有锁A的情况下尝试获取锁B;与此同时,第二个线程先获取锁B,然后在持有锁B的情况下尝试获取锁A。此时两个线程都会进入阻塞状态,互相等待对方释放锁,从而形成死锁局面。这种僵持状态会导致程序无法继续执行。具体代码表现如下:
java
Object locker = new Object();
String locker1 = "locker1";
Thread t1 = new Thread(()->{
for(int i=0;i<50000;i++){
synchronized (locker){
synchronized (locker1){
count++;
}
}
}
});
Thread t2 = new Thread(()->{
for(int i=0;i<50000;i++){
synchronized (locker1){
synchronized (locker){
count++;
}
}
}
});
t1.start();
t2.start();
这里在和大家提一下Java标准库里面一些线程不安全和线程安全的类。
线程不安全的:ArrayList,LinkedList,TreeMap,HashSet,TreeSet,StringBuilder
线程安全的:Vector(不推荐使用),HashTable(不推荐),StringBuffer。ConcurrentHashMap
上面的线程安全都是使用了一些锁机机制来控制的,还有一个虽然没有加锁,但是不涉及"修改"所以同样是线程安全的如String类。
四、Volatile
Volatile是可以保证内存可见性的,当共享变量被 volatile 修饰后,JVM 会为它添加内存屏障,强制约束读写行为,整套流程发生改变:
-
读取规则 :线程每次使用
volatile变量时,必须直接从主内存读取最新值,不再使用工作内存中缓存的旧数据。 -
写入规则 :线程修改完
volatile变量后,必须立刻将新值同步刷新到主内存,不能缓存在工作内存中。
简单来说:读必读主存,写必刷主存。
举个场景辅助理解: 变量 count 被 volatile 修饰,线程 t1 修改count后,会马上把新值推送至主内存;线程 t2 下一次读取时,会直接从主内存获取这个最新值,两个线程之间的数据就做到了实时同步,彻底解决了可见性问题。
但是要注意Volatile是不可以保证原子性的以上面的代码为例子
java
class Count{
volatile public static int count = 0;
public static int add(){
return ++count;
}
}
public class Test12 {
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
String locker1 = "locker1";
Thread t1 = new Thread(()->{
for(int i=0;i<50000;i++){
Count.add();
}
});
Thread t2 = new Thread(()->{
for(int i=0;i<50000;i++){
Count.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(Count.count);
}
}
这样子也是没有办法保证最后的结果是10w的。
五、总结
总的来说,多线程之所以会出现数据错乱,核心就是原子性、可见性、有序性三大问题。count++并非单一操作,加上线程内存模型与指令重排序,最终导致结果出错。
想要解决这类问题,我们可以根据需求选择不同方案:synchronized关键字通过加锁实现线程互斥,能完美保证代码原子性,也是解决线程安全问题的主流方式,不过使用多把锁时要警惕死锁风险。而volatile只能解决可见性与部分有序性问题,不能保证原子性,因此处理自增这类复合操作时单独使用它并不生效。
另外我们也了解了 Java 中常用类的线程安全特性,实际开发中大家要结合业务场景合理选择技术方案,规避多线程带来的隐患。