Java线程安全问题

发布时间 2023-12-03 19:22:46作者: 我爱这世间美貌女子

一、共享资源

共享资源是指,同时会有多个线程访问的资源。

二、线程安全问题

线程安全问题是指多个线程同时读写共享资源时并且没有任何同步措施的情况下,出现脏数据或者其他不可预见的结果的问题。当然如果所有线程都只是读取共享资源而不去修改共享资源是不会出现线程安全问题的。

三、Count计数器线程安全问题

在计数器类实现中,计数器变量count是一个共享变量也就是共享资源,当一个线程对count进行计数时,其步骤是:读取变量的值->对变量进行计数->将计数后的变量写回主内存三个步骤。

image

count初始值为0,线程A在t1时刻读取count的值,在t2时刻递增count的值,此时线程B从内存读取count的值(读取到的值为0),在t3时刻线程A将新的count值(1)写回主内存,线程B在t3时刻递增count的值,在t4时刻线程B将新count值(1)写回主内存。两个线程在对count值进行递增后,count最终值为1,明明是两次计数结果却不是2,这就是共享变量的线程安全问题。

四、Java内存可见性

4.1 Java内存模型

多线程处理下共享变量时的Java内存模型如下所示。

java内存模型

Java的内存模型规定将所有变量存放在主内存,当线程使用变量时,把主内存的变量复制到自己的工作内存里。线程读写操作的都是自己工作内存中的变量。

Java内存模型是一个抽象的概念,并不真实存在,下面是Java内存模型对应的CPU内存架构图:

cpu内存架构

上图为一个两个核心的CPU架构图,每个核都有自己的控制器和运算器以及一个独享的一级缓存。两个核共享的一个二级缓存和主内存。Java内存模型中的私有内存对应的就是一级缓存,主内存对应的是二级缓存或者是主内存。

4.2 共享变量内存不可见问题

参照Java内存模型和CPU架构图,我们模拟一下Count计数器示例中存在的Java共享变量内存不可见问题。

首先,线程A的操作如下:

线程A读取count(count=0)的值,先在一级缓存中查找count的值,此时一级缓存没有count的值,继续到二级缓存里找count的值,也没找到,到主内存里找count的值,将count的值复制一份副本到二级缓存和一级缓存(count=0);线程A对一级缓存里count执行计数操作(count=1);线程A将count的值写回到二级缓存和主内存。

此时,线程B执行如下操作:

线程B读取count的值,先在缓存中查找count的值,此时一级缓存没有count的值,继续到二级缓存中查找,找到count的值为1,将count值复制到线程B的一级缓存;线程B对一级缓存里的count值进行计数操作(count=2);线程B将count值写回二级缓存和主内存。

此时线程A再对count进行计数操作,这一次线程A在自己的一级缓存查找count的值,找到值为1;对count进行计数操作,此时count的值为2,然后将值写回二级缓存和主内存。我们知道在经过线程B的计数操作后,二级缓存和主内存中count的值为2,在线程A第二次进行计数操作后count的值应该是3,然而事实是经过线程A的第二次计数count的值依然是2。这就是内存不可见问题,线程B写入的值对线程A不可见。

4.3 synchronized关键字解决内存不可见问题

synchronized代码块是Java提供的一个原子性内置锁,通常也叫做内部锁或者监视器锁。监视器锁是一个排他锁,也就是当一个线程获得监视器锁后,其他线程会被阻塞等待,直到该线程释放监视器锁后才能获取锁。监视器锁在synchronized代码块正常退出、抛出异常或者调用wait系列方法时释放。Java线程和操作系统原生线程一一对应,阻塞一个线程是需要从用户态切换到内核态,所以synchronized的使用会导致上下文切换并带来线程调度开销。

synchronized的内存语义是,进入synchronized代码块时把synchronized代码块里面用到的共享变量在线程的工作内存中清除,这样使用到synchronized块里面的变量时就不会从工作内存里面取。退出synchronized块时,把在工作内存里对共享变量的修改刷新到主内存。从而解决共享变量内存不可见问题。

4.4 volatile关键字解决内存不可见问题

上文介绍了使用synchronized关键字通过加锁释放锁可以解决共享变量的内存不可见问题,但是使用synchronized关键字会带来线程上下文切换的开销。除了synchronized关键字外Java还提供了一种弱形式的同步,也就是使用volatile关键字。该关键字可以确保对一个变量的更新对其他线程马上可见。当一个变量被声明为volatile时,线程在写入变量时不会把变量缓存在寄存器或其他地方,而是会把变量直接刷新会主内存。当其他变量读取该共享变量时,会从主内存重新获取最新值,而不是从当前线程的工作内存中取。

volatile的内存语义是,当线程写入volatile变量时,相当于线程退出synchronized代码块,把共享变量的值刷新回主内存;当线程读取volatile变量时,相当于进入synchronized代码块,先把该变量从工作内存里面清除,然后从主内存中读取最新值。

4.5 synchronized和volatile的区别

synchronized会获取锁和释放锁,synchronized代码块会导致线程阻塞,会造成线程上下文切换和线程重新调度的开销;volatile是非阻塞算法,不会造成线程上下文的开销。

synchronized关键字解决共享变量内存不可见问题和操作的原子性操作,volatile解决了共享变量不可见问题,但不保证操作的原子性。

五、Java原子性操作

所谓原子性操作是指,执行一系列操作时,这些操作要么全部执行,要么全部不执行,不存在只执行其中一部分的情况。如果不能保证操作的原子性操作就可能会出现线程安全问题。

Java中有一个典型的非原子性操作是自增(自减)操作,++i。

一个++i操作是由如下步骤完成的:

  • 读取i的值
  • 将i的值加1
  • 将加1后的结果赋值给i

这不是一个原子操作,如果不能保证三个步骤的原子性,那就可能出现线程安全问题。

5.1 使用synchronized关键字保证操作原子性

通过使用synchronized关键字可以保证内存可见性和原子性,从而实现线程安全。

5.2 使用CAS操作保证原子性

CAS(Compare and Swap)是JDK提供的非阻塞原子性操作,它通过硬件保证比较-更新操作的原子性。JDK里面的Unsafe类提供了一系列的CAS操作。

六、Java指令重排

Java内存模型允许编译器和处理器对指令重排序以提高运行性能,并且只会对不存在数据依赖性的指令重排序。当线程下,重排序可以保证最终执行结果和程序顺序执行结果一致,但在多线程下就会存在问题。

6.1 通过volatile避免指令重排序问题

通过把变量声明为volatile可以避免指令重排序问题。

写volatile变量时可以保证volatile写之前的操作不会被编译器重排序到volatile写之后。读volatile变量时可以保证volatile读之后的操作不会被编译器重排序到volatile写之前。