Java----多线程同步机制

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/m0_38089373/article/details/81304744

一.简介

在现在的计算机操作系统系统中,大多都支持多线程进行多任务的处理,但是对随之也产生一个问题,多线程多共享数据的访问的同步,这就是多线程的并发问题。

  • 并行性:在同一时刻,处理器同时处理多条指令
  • 并发性:在同一时刻,处理器只能执行一条指令,但是可以在不同的线程之间进行快速切换,执行不同线程之间的指令,使得表面上可以执行多个指令。

二.几个概念

(一).关于底层硬件

由于现代计算机的存储设备和处理器的执行速度差距很大,内存的空间大但是速度慢,而处理器的速度又很快,导致一种供不应求的现象产生,因此计算机系统都加入一层速度尽可能快的高速缓存作为内存和处理器之间的缓冲。虽然这种方式解决了速度的矛盾,但是也产生一个新的问题:每个处理器有自己的缓存,又共享着同同一主存,这就可能产生一个处理改变了主存中的值,而其他处理器中的缓存值未改变的情况,这就是缓存一致性。解决这个问题的方法就是在处理器之间定义一种缓存协议,使得所有的缓存具有一致性。
image.png

同时在计算机的处理器中,有时为了充分利用处理器的能力,可能在不改变计算结果的前提下对一些指令进行优化,重新调整指令的执行顺序,从而使处理器的性能得到利用。

(二).java 内存模型

1.简介

Java 虚拟机为了实现 Java 程序在不同平台能有一致性的内存访问效果,定义了一种内存模型,用来屏蔽各种硬件和操作系统访问内存的差异,这就是 Java 内存模型 .

Java Memory Model ,简称 JMM

2.主内存和工作内存

在 JMM 中,所有的共享数据的变量 (实例字段,静态字段和数组元素)都存储在主内存中,所有的线程都可以访问,而对于每条线程,又有自己的工作内存,线程之间无法访问另一个线程的工作内存,工作内存中保存着主内存数据的一个副本,用于线程直接读写。和前面的硬件的一些概念有些相似,这里的主内存可以比作硬件中的主内存,而工作内存就可以比作缓存区域,同样工作内存和主存之间也定义了一些协议,用于实现两者之间的交互。

操作 含义
lock 对主存的变量进行锁定
unlock 对一个锁定的内存变量进行释放
read 对一个主存的变量进行读取 ,后接 load 操作
load 将读取的数据加载到工作内存的数据副本中
use 将工作内存中的变量传递给执行引擎
assign 将执行引擎的值传递给工作内存
store 对工作内存的变量传递给主内存,后接 write
wirite 将工作内存的变量写入主存中

上述操作在计算机执行指令中都是原子的,也就是不可再划分为多个指令的,通常称具有原子性。
image.png

3.可见性

可见性指的是当一个线程修改了共享变量的值,其他线程能够立即知道这种修改,从而导致其他线程中的缓存数据失效。

4.数据依赖性

当两个操作同时访问同一个变量的时候,如果这个操作的顺序不同会对变量或者操作本身或者后续操作产生影响的时候,就说这两个操作有数据依赖性。数据依赖可分为三种情况:

名称 代码示例
先写后读 a = 1; b = a;
先写后写 a = 1; a = 2;
先读后写 a = b; b = 1;

5.重排序

之前说过为了充分利用处理器的性能,会对指令进行的顺序重新调整,从而充分利用处理器的性能,同样在 JMM 中,为了提高代码执行的性能,编译器和处理器会对指令进行重排序。主要有三种类型:

  • 编译器优化重排序,这种重排序在保证不改变单个线程内程序执行的结果前提下重新安排语句的执行顺序。
  • 指令级的并行重排序,这种重排序是为了实现多条指令可以重叠的执行,但是需要在指令中包含的数据不存在数据依赖性的情况下可以对指令的顺序排序
  • 内存系统的重排序,这是由于处理器有缓存和读写缓冲区,因此load 和 store 指令在这里是可能乱序执行的。

其中指令级重排序和内存系统重排序可以同一为处理器重排序。
在单个线程中,如果两个线程有数据依赖性,编译器和处理器就不会对两个操作进行重排序。

6.先行发生原则

happens-before

由于编译器和处理器存在着重排序的问题,在多线程指令切换的过程中就可能出现问题,因此 虚拟机对一些情况进行了规定,对于可能造成错的语句禁止重排序。这里的另一种说法就是为了表明各个操作之间的内存可见性,也就是前面的第 4 点,在 JMM 中如果一个操作的执行结果要对另一个操作可见,那么它们之间就要有存在先行发生关系,这两个操作可以是在一个线程内也可以是不同的线程。这里的先行发生原则和虚拟机禁止重排序的规定可以说指的是同一个东西。主要的规则如下:

  • 程序顺序规则:在一个线程内,每个操作先行发生后续的操作,
  • 监视器锁规则:对同一个锁,前一个线程的 unlock 操作先行发生于后一个线程的 lock (两个线程可以是同一个线程)
  • volatitle 变量原则:对一个 volatitle 变量的写操作先行发生对这个变量的读操作。
  • 线程启动规则:Thread 对象的 start 先行发生于线程内的每一个动作。
  • 线程启动规则:线程内的每一个动作都先行发生于线程的中止检测
  • 线程的中断规则:对线程的中断 interrupt ,先行发生于线程检测到中断事件。
  • 对象终结规则:一个对象的初始化先行于 finalize
  • 传递性:操作 A 先行发生于操作 B,操作 B 先行发生于操作 C ,那么操作 A 就先行发生于操作 C.

这里要注意的是先行发生和时间上的先发生并没有任何关系,先行发生原则要求的是前一个操作的执行结果对后一个操作可见,且前一个操作按顺序排在第二个操作之前,举个例子:

        //同一线程下
        int i;
        int j ;

        i = 1;
        j = 2

由第一个规则可知,i = 1; 先行发生于 j = 2;但是因为它们没有数据依赖性因此可能进行重排序, j = 2; 很有可能被处理器先执行,但是这种排序并不会导致程序出错。但是在下面这种情况

         //同一线程下
        int i;
        int j ;

        i = 1;
        i = 3;
        j = 2//另一个线程
        int k ;
        while(true){
            if(j==2){
              k = i + j;
           }
        }

我们期望得到的是 k = 5,但是 这种情况下两个线程之间并不符合先行原则,所以再重排序下,很可能先执行了j = 2,而导致 k != 5,因此就可能出现问题。解决方式就是使这种关系符合先行原则,以 volatitle 变量原则 为例,添加 volatitle 修饰

    volatitle int i;
    volatitle int j ;

由于 volatitle 变量规则,对写先行于读,因此只有在 i 和 j 都写后,另一个线程中才能 读。从而保证了多线程的安全性。

三.同步的方式

在有了上面的概念后下面就进行具体说明多线程实现线程安全的几种方式以及其中的原理。

1. volatile

当定义了一个 volatile 变量后,这个变量就就有两种特性:

  • 可见性
  • 禁止指令重排序

(1).可见性

对一个声明了 volatile 变量进行读写的时候,会在指令中多出 lock 前缀,这个前缀会产生两种效果:

  • 将缓存的数据写回内存
  • 使其它处理器的缓存中的数据失效

通过这两个效果就会使得 volatile 修饰的变量具有可见性

(2).禁止指令重排序

仅仅保证可见性显然是不够的,由于重排序的问题,如果不对指令的排序进行处理,仍然会出错。为了阻止重排序,volatile 变量的操作会在指令中设置内存屏障(可以禁止一些指令排序时的往前或者往后排序)。具体的内存屏障可以分为四种

类型 说明
LoadLoad 确保前一个load 先执行于后续的 load
StoreStore 确保前一个 store 先执行与后续的 store
LoadStore 确保先 load 后执行 store
StoreLoad 确保先 store 后执行 load,这会使前面所有操作执行完成后才执行后面的

不能重排序的情况如下
image.png
具体的内存屏障设置规则如下:(这里 JMM 采取的是一种保守策略为的就是最小代价满足上述情况)

操作 设置的内存屏障
volatile 读 在读操作后面连续设置LoadLoad,LoadStore
volatile 写 在写操作前后分别设置StoreStore,StoreLoad

设置内存屏障后的指令序列就如下图:
image.png

当然如果频繁的读写就会造成过多的内存屏障的设置,因此不同的处理器会对指令进行不同的优化,减少一些内存屏障,这里就不再说明。

适用条件

在了解了 volatile 的特性后,看起来 volatile 已经处理了线程安全的问题,但是实际上并不是这样的,由于处理器设有缓冲区和缓存,且 volatile 只是保证了可见性和有序性,但是不具备原子性,比如下面这段:

    volatile int i = 0;
    for(循环10次){
        i++;
    }

    //另一线程
    for(循环10次){
        i++;
    }

在对于 i++ ,时间上等于 i = i+1;是先读后写的两个操作 如果是单线程操作,这并没有问题,但是如果有多个线程进行写操作,就有可能再读取后,已经到了操作栈而不是缓存中,而这个时候另一个线程进行了写操作,显然 i 的值在后面的读取就不一定是预期的20.因此可以看到 volatile 适用的场景要符合的条件:

  • 对变量的写操作不依赖于当前值,或者只有一个线程对变量进行修改
  • 该变量没有包含在具有其他变量的不变式中

实际上 volatile 并不是一种正规的线程安全的实现方法,只是由于它具有可见性和有序性两个特性,因此在一些多线程场景就可以利用这两个特性进行安全问题的处理。

2.synchronized

synchronized 是线程安全的同步机制中属于互斥同步的一个最基本的手段。互斥同步是指在多个线程并发访问共享数据时保证共享数据在同一时刻只被一个线程使用。通常数据被一个线程使用的时候就说这个线程获得了这个对象的锁。

(1).synchronized 实现同步的基础

synchronized 的使用可以分为三种情况

  • 同步普通方法
  • 同步静态方法
  • 同步方法块

对于同步方法块是使用 monitorenter 和 monitorexit 指令实现的,对于方法的同步时在细节上使用另外一种方式实现的,但是同样可以使用这两个指令实现。

上述两个指令都需要一个 reference 类型指明锁定的和解锁的对象

  • 对于普通同步方法,锁是当前的实例对象
  • 对于静态同步方法,锁是当前类的 class 对象
  • 对于同步方法块,锁是 synchronized 指定的对象

(2).对象头

synchronized 用的锁是存在 Java 的对象头里的,对象头的存储结构如下:

锁状态 25bit 4bit 1bit 2bit
无锁状态 对象的 HashCode 对象的分代年龄 偏向锁标志 锁的标志

如果一个对象没有被锁定,那么它的 锁的标志就是 01,如果被锁定就修改相应的值。

在执行 monitorenter 指令的时候,首先要尝试获取对象的锁,如果这个对象没有被锁定,或者当前的线程已经有了对象的锁,那么这个对象中锁的计数器就加 1 ,执行 monitorexit 后锁的计数器就会减 1,锁的计数器为0 ,那么这个对象就被释放。当一个线程获得锁后,其他想要访问的线程就要阻塞等待知道对象的锁被释放。

在有了monitorenter 和 monitorexit 的底层实际上就是 lock 和 unlock 操作,所以 synchronized 修饰的代码块之间的操作就具备了原子性,同时也有了可见性。

3.ReentrantLock

ReentrantLock 是可以实现锁的另一种方式,这两者都有线程的重入性

重入性是指一个获得对象的锁的线程可以对这个对象再次锁定,这样锁的计数器就会再次加 1.

不过 ReentrantLock 提供了更多地功能:等待中断,可实现公平锁,锁可以绑定多个条件。

  • 等待中断是指其他正在等待锁的线程可以选择放弃等待,处理其他事情。
  • 公平锁是指按申请锁的时间依次获得锁,而非公平锁是指在释放锁的时候,任何等待的线程都有机会获得锁。synchronized的 锁是非公平的,而 ReentrantLock 默认也是非公平的,但是可以指定为公平锁。
  • 绑定多个条件是指 ReentrantLock 可以同时绑定多个 Condition 对象,而
    synchronized 默认的只能是一个对象.

锁的优化

线程之间获得锁和释放锁的过程涉及了线程的切换,因此就会带来了性能上的消耗,为此对前面说的方式进行了优化,将锁的状态划分为 4 个状态:无锁状态 -》 偏向锁-》轻量级锁-》重量级锁,锁的状态只会由低级膨胀到高级。

1.非阻塞同步

互斥同步就是把线程进行阻塞或唤醒。不管是否有数据共享的竞争,都进行一些类似加锁,状态转换的操作,这是一种悲观的策略。随着硬件的发展,现在有了一种基于冲突检测的乐观的并发策略,就是如果没有竞争,获取数据直接成功,如果有竞争再采取措施(一般是不断地重试,知道成功),这种策略并不用把线程挂起。
操作和冲突检测在现今的硬件已经可以实现具备原子性,比如 比较并交换

比较并交换 Compare - and - Swap 简称 CAS

CAS 操作有 3 个操作数,内存上的值,旧的预期的值,和新的值,如果内存值符合旧预期值就用新值更新内存值,否则就不更新,最后都返回内存的旧值。

当然这种方式存在一个 ABA 问题,就是如果内存值是 A ,后来改成了 B ,又改回了 A ,那么 CAS 还是认为这个值没有改变。虽然有这个问题但是在大部分情况下并不会影响并发的安全。

2.偏向锁

大多数情况下,锁不仅不存在多线程竞争,而且总是由同
一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并
获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程在进入和退出
同步块时不需要进行 CAS 操作来加锁和解锁只需简单地测试一下对象头的Mark Word里是否
存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需
要再测试一下 Mark Word 中偏向锁的标识是否设置成1(表示当前是偏向锁)如果没有设置,则
使用 CAS 竞争锁;如果设置了,则尝试使用 CAS 将对象头的偏向锁指向当前线程

偏向锁使用了一种等到竞争出现才释放锁的机制,如果没有其它线程竞争锁那么不管这个线程是否活动,它都暂时不释放。

3.轻量级锁

获得锁线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并
将对象头中的 Mark Word 复制到锁记录中,官方称为 Displaced Mark Word。然后线程尝试使用
CAS 将对象头中的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失
败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

轻量级解锁时,会使用原子的 CAS 操作将 Displaced Mark Word 替换回到对象头,如果成
功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

4.自旋锁

有时一个线程获得锁的时间并不会太长,如果让其他线程在一个短时间内去挂起和恢复同样会对性能带来考验,因此在获得锁失败后可以让线程等待一下,也就是让线程进行一个空循环,也就是自旋,如果自旋操作一定次数后还没有获得锁那么就会让线程挂起。

5.锁的消除

这是指在编译器运行的时候,如果检测到不可能存在数据共享竞争的锁就会进行消除。这里主要的判断依据源于逃逸分析的数据支持,如果一段代码中堆上所有的数据不会逃逸出去被其他线程访问,那就可以把它当作栈中的数据,认为是线程私有的,因此就不用同步加锁。

6.锁粗化

如果有一系列联系的操作都对一个对象反复加锁和解锁,那么虚拟机就会将锁的范围扩大到这整个范围避免不必要的性能的浪费。

猜你喜欢

转载自blog.csdn.net/m0_38089373/article/details/81304744