JAVA:线程安全问题及解决方案
线程安全的概念:
如果在多线程环境下代码运行结果是符合我们预期的,就是如果在单线程环境应该的结果,那我们就说这个程序是线程安全的。
问题:
1、操作系统对线程的调度是随机的,抢占式执行
2、多个线程同时修改同一变量
3、修改操作不是原子的
4、指令重排序
5、内存可见性
解决方案:
针对问题1:
随机调度是系统设置,我们无法对此进行修改,但是也不是放任这种问题不管了,随机调度使我们代码的执行顺序存在很多变数,不一定会按照我们想要的顺序执行,程序员就需要保证在不同执行顺序条件下,代码都能正常执行
针对问题2、3:
问题2可以说是问题3的一个具体案例
public class ThreadDemo {public static int count=0;public static void main(String[] args) throws InterruptedException {Thread thread1=new Thread(()->{for(int i=0;i<50000;i++){count++;}});Thread thread2=new Thread(()->{for(int i=0;i<50000;i++){count++;}});thread1.start();thread2.start();thread1.join();thread2.join();System.out.println(count);}
}
这里预期运行的结果应该是10W,但是 实际上不是,这里就涉及到多个线程同时修改count变量,结果也不是我们的预期结果。
count++:这一步其实需要3个CPU指令完成,分别是将count内存中的值加载到寄存器;把寄存器中的内容+1;把寄存器中的内容保存回内存里
我们知道操作系统对线程的调度是随机的。可能出现 在 thread1 中进行到第2步,还没保存到内存里呢,操作系统又调走执行 thread2 ,本来应该count已经加过1了但是没有保存到内存里就被覆盖了,就出现运行结果少于10W的情况,当然还会有其他调度的顺序,这里就不在一一列举了。
这也是因为修改操作不是原子的,什么是原子性?
原子性在计算机中是指一个操作或者一系列操作要么全部执行成功,要么全部不执行,不存在部分执行的情况。
例如上面代码中 count++ 操作在CPU指令中分三步执行,中途突然不执行了,就不是原子性的。
怎么保证java的一串代码或者一条代码是原子性的?
Java中提供了加锁操作(synchronized),这样运行结果就是正确的。
public class ThreadDemo {public static int count=0;public static void main(String[] args) throws InterruptedException {Object lock=new Object();Thread thread1=new Thread(()->{for(int i=0;i<50000;i++){synchronized (lock){count++;}}});Thread thread2=new Thread(()->{for(int i=0;i<50000;i++){synchronized (lock){count++;}}});thread1.start();thread2.start();thread1.join();thread2.join();System.out.println(count);}
}
针对问题4:
在执行程序时为了提高性能,编译器与处理器通常会给代码进行优化,做指令重排序
重排序分三种类型:
编译器优化的重排序 编译器在不改变单线程程序语义的前提下(代码中不包含synchronized),可以重新安排语句的执行顺序。
指令级并行的重排序 现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
内存系统的重排序 由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行
如果是单线程,JVM与CPU指令会进行优化,并不会影响什么
如果在多线程的情况下,代码执行复杂度很高,如果编译器优化把执行逻辑变了,很可能会导致与原本我们想要的结果发生变化,得出我们并不想要的结果,导致线程不安全。
重排序会带来的问题就是可能会导致出现内存可见性问题。
针对问题5:
可见性是说,一个线程对共享变量值的修改能够及时的被其他线程看到
JVM中规范定义了Java内存模型,我们都知道Java有个特点:一次编译,到处运行
Java内存模型就屏蔽了各种硬件与操作系统的内存访问差异,Java程序在各种平台下都能达到一致的并发效果。
线程的共享变量存在主内存
每个线程都有自己的工作内存
Load是把内存中的变量值加载到寄存器
Save是把寄存器中的内容保存回内存中
当线程要读取一个共享变量时,会先把主变量拷贝到工作内存中,再从工作内存读取数据
当线程要修改共享变量的值时,会先修改工作内存拷贝的变量值,再同步到主内存
了解以上以后,我们来具体看一下内存可见性为什么会导致线程不安全
我们了解到每个线程都有自己的工作内存,工作内存的内容相当于共享变量的副本,假设有两个线程,线程1修改工作内存中的共享变量值还未同步到主内存,线程2进行读取或者修改操作还是原本没有修改的变量(线程2从主内存中读取,线程1把副本修改了但是没有同步到主内存中,共享变量值没被修改),这就导致了线程不安全
可以通过以下来解决:
1、volitale关键字
public class ThreadDemo1 {public static volatile boolean flag=false;public static void main(String[] args) {new Thread(()->{try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}flag=true;System.out.println("线程修改了flag值为true");}).start();while(!flag){//主线程不断检查flag的值}System.out.println("主线程检测到flag值变为true,程序结束");}
}
如果不加 volatile 关键字,主线程就一直无法感知到flag被修改为true,陷入死循环。加上 volatile 关键字后就能保证主线程可以及时获取flag的最新值
2、使用synchronized关键字
public class ThreadDemo2 {private int count=0;public synchronized void increment(){count++;}public static void main(String[] args) throws InterruptedException {ThreadDemo2 threadDemo2=new ThreadDemo2();Thread thread1=new Thread(()->{for(int i=0;i<50000;i++){threadDemo2.increment();}});Thread thread2=new Thread(()->{for(int i=0;i<50000;i++){threadDemo2.increment();}});thread1.start();thread2.start();thread1.join();thread2.join();System.out.println("count="+threadDemo2.count);}
}
把 synchronized 修饰 increment 方法,保证同一个时刻只能有一个线程进入方法修改count值,避免了多个线程同时修改count变量导致的内存可见性与数据不一致问题。
3、使用并发工具类
Java并发包提供了一些工具类,类似 AtomicInteger、AtomicLong 等原子类,他们通过硬件级别的原子操作来保证变量的可见性与原子性,从而避免了内存可见性问题
import java.util.concurrent.atomic.AtomicInteger;public class ThreadDemo3 {private AtomicInteger count=new AtomicInteger(0);public void increment(){count.incrementAndGet();}public static void main(String[] args) throws InterruptedException {ThreadDemo3 threadDemo3=new ThreadDemo3();Thread thread1=new Thread(()->{for(int i=0;i<50000;i++){threadDemo3.increment();}});Thread thread2=new Thread(()->{for(int i=0;i<50000;i++){threadDemo3.increment();}});thread1.start();thread2.start();thread1.join();thread2.join();System.out.println("count="+threadDemo3.count);}
}
incrementAndGet 方法是对一个数值进行原子性的递增操作并返回递增后的值
使用 AtomicInteger 类来管理计数器 atomicCount , incrementAndGet 方法是原子操作,保证了多线程环境下对 atomicCount 的修改是线程安全的,避免了内存可见性带来的问题。