当前位置: 首页 > 科技观察

你的Java并发程序bug是100%由这些原因造成的

时间:2023-03-14 15:32:13 科技观察

可见性问题可见性是指一个线程修改了一个共享变量,其他线程可以立即看到共享变量的更新值。这似乎是一个合理的要求,但在多线程的情况下,你可能会失望。由于每个CPU都有自己的缓存,每个线程都可能使用不同的CPU,这样就会造成数据可见性的问题,先看下图:CPU缓存和主存的关系。对于一个共享变量count,每个CPU缓存都有一个count的副本,每个线程只能对共享变量count进行操作。操作所在CPU缓存中的副本,不能直接操作主存或其他CPU缓存中的副本,也会造成数据差异。多线程导致可见性导致程序问题的一个典型案例就是变量的累加,比如下面的程序:publicclassDemo{privateintcount=0;//每个线程都是count+10000publicvoidadd(){for(inti=0;i<10000;i++){count+=1;}}publicstaticvoidmain(String[]args)throwsInterruptedException{for(inti=0;i<10;i++){Demodemo=newDemo();Threadt1=newThread(()->{演示.add();});Threadt2=newThread(()->{demo.add();});t1.start();t2.start();t1.join();t2.join();System.out.println(demo.count);}}}我们用了两个程序累加count变量,每个线程累加10000次。按道理最后的结果应该是20000次,但是你执行多次后,你会发现结果不一定是20000次,这是共享变量可见性的问题。我们启动两个线程t1和t2。当线程启动时,它会将当前主存的计数读取到自己的CPU缓存中。此时count的值可能是0或者1或者其他,我们默认为0,每个线程都会执行count+=1的操作,这是一个并行操作,CPU1和CPU2的缓存中的count都是1,然后他们分别将各自缓存中的计数写回主存,此时主存中的计数也是1,而不是我们预期的2。这个原因是由数据可见性引起的。原子性问题原子性:即一个操作或多个操作要么全部执行并且执行过程不会被任何因素打断,要么根本不执行。这种原子性是针对CPU级别的,而不是我们Java代码中的原子性。取计数+=1;以我们的可见性演示程序中的命令为例。这条Java命令最终会编译成如下3条CPU指令:将变量count从内存中加载到CPU的寄存器中,假设count=1,在寄存器中执行count+1操作,count=1+1=2,将结果+1后的count写入内存,这是一个典型的read-modify-write操作,但不是原子的,因为多核cpu之间存在竞争,不是某个cpu一直执行,它们会不断的夺取执行权和释放执行权,所以上面3条指令不一定是原子的具体来说,下图是两个线程count+=1指令的模拟过程:非原子操作线程1的CPU后执行完前两条指令,执行权被线程2的CPU抢占,此时线程1所在的CPU被挂起,等待再次获取执行权。线程2所在的CPU获得执行权后,首先从内存中读取计数。指令,线程2执行完后,内存中的count等于2,此时线程1再次获得执行权。此时线程1只有最后一个命令将count写回内存。执行后,count的值还是2,而不是我们预期的3。有序问题有序:程序执行的顺序是按照代码的先后顺序执行的,比如下面的代码1inti=1;2intm=11;3longx=23L;根据顺序,需要按照代码的先后顺序执行,但是执行结果不一定是按照这个顺序来的,因为JVM为了提高程序的运行效率,会在JVM编译器认为更好的顺序,这可能会打乱代码的执行顺序。它会保证程序最终的执行结果与代码顺序执行的结果一致,也就是我们所说的指令重排序。指令重排序导致程序bug的一个典型案例是:没有使用volatile关键字的双重检测锁单例模式,如下代码:publicclassSingleton{staticSingletoninstance;publicstaticSingletongetInstance(){//第一次判断if(instance==null){//锁,只有一个线程可以获得锁synchronized(Singleton.class){//第二次判断if(instance==null)//构造对象,里面很有学问instance=newSingleton();}}returninstance;}}双重检测锁方案看起来很完美,但是在实际运行中,会出现bug,会不会出现对象逃逸的问题,可能会得到一个未构造的Singleton目的。这就是构造Singleton对象时指令重排序的问题。我们先来看看构造理想类型对象的操作指令:指令1:分配一块内存M;指令2:初始化内存M上的Singleton对象;指令3:然后将M的地址赋给实例变量。但是在实际的JVM编译器上可能不是这样,可能会优化成如下指令:指令1:分配一块内存M;指令2:将M的地址赋值给实例变量;指令3:最后,在内存M上初始化Singleton对象。好像一个小的优化,就是这么小的优化会让你的程序不安全。假设抢到锁的线程执行完指令2后,此时的实例不再为空。线程C,线程C看到的实例不为空,会直接返回实例对象。此时实例还没有初始化成功,调用实例对象的方法或成员变量时可能会触发空指针异常。可能的执行流程图:双重检测锁单例模式无volatile关键字以上就是Java程序在多线程情况下出现bug的三个原因。JDK公司也针对这些问题给出了相应的解决方案,具体如下图所示,我们稍后再讨论这些解决方案的更多细节。并发解决机制