Java多线程高并发系列:(四)线程安全-原子性和Synchronized

2024-10-25 16:53
18
0

一、原子性

原子操作:原则操作可以是一个步骤,也可以是多个操作步骤,但是其顺序不可以被打乱,也不可以被切割而只执行其中的一部分(不可中断性)。

将整个操作视作一个整体,资源在该次操作中保持一致,这是原子性的核心特征。

竟态条件:当两个线程竞争同一个资源时,如果对资源的访问顺序敏感,就称存在竟态条件。

临界区:导致静态条件发生的代码区。

1.1、CAS(Compare and swap)机制

CAS 属于硬件同步原语,处理器提供的内存操作指令,保证原子性。

CAS 操作需要两个参数,一个旧值和一个目标值,修改前先比较旧值是否改变,如果没变,将新值赋给变量,否则不做改变。

JAVA中的sun.misc.Unsafe类提供了CAS机制(内存中同一时刻只能一个线程对其修改)。

基本Java对线程安全操作,最后底层都调用了unsaffe如下三个方法之一:

有4个参数:

  • 第一个参数为要修改的对象
  • 第二个参数为要修改对象的偏移量
  • 第三个参数为要修改的旧值
  • 第四个参数为字段要改成的值

compareAndSwapObject要修改第一个参数的值为第四个参数,那么第三个参数就传null。

JDK9之后sun.misc.Unsafe类被jdk.internal.misc.Unsafe替代,可看另一篇文章

1.2、Java原子操作封装类

原子更新基本类型类:

  • AtomicBoolean:原子更新布尔类型
  • AtomicInteger:原子更新整型
  • AtomicLong:原子更新长整型

原子更新数组:

  • AtomicIntegerArray:原子更新整型数组的元素
  • AtomicLongArray:原子更新长整型数组的元素
  • AtomicRefernceArray:原子更新引用类型数组的元素

原子更新引用类型:

  • AtomicReference:原子更新引用类型
  • AtomicReferenceFileldUpdater:原子更新引用类型里的字段
  • AtomicMarkableReference:原子更新带有标记位的引用类型,可以原子更新一个布尔类型的标记位和引用类型。

原子更新字段类:

  • AtomicIntegerFieldUpdater:原子更新整型的字段的更新器
  • AtomicLongFieldUpdater:原子更新长整型字段的更新器
  • AtomicStampedReference:原子更新带有版本号的引用类型,该类将整数值与引用关联起来,可用于原子的更新数据和数据版本号,可以解决使用cas进行原子更新时可能出现的aba问题

Java1.8新增计数器:

  • DoubleAdder、LongAdder:计数器增强版,高并发下性能更好
  • DoubleAccumulator、LongAccumulator:计数器增强版,可自定义累加规则

1.3、CAS存在的问题

  • 仅针对单个变量的操作,不能用于多个变量来实现原子操作。
  • 循环+CAS,自旋的实现让所有线程都处于高频运行,争抢CPU执行时间的状态 。如果操作长时间不成功,会带来很大的CPU资源消耗。
  • ABA问题,无法体现出数据的变动(ABA 问题是在多线程中都可能存在,CAS对单一值进行处理时也可能存在)。

ABA问题详解:

 

  • thread1、thread2同时读取i=0,执行CAS(0,1)操作
  • thead1先thread2执行成功,紧接着thread1又执行了CAS(1,0),将i的值改回了0.
  • 那么thread2本应该失败的CAS(0,1)操作却成功了。

ABA的问题什么情况会出现?

  • AtomicInteger等一些要么都是加要么都是减的操作不会出现。
  • AtomicReference对单一值进行CAS可能会出现。
  • 高层代码块中,如果没有实现原子性,也可能出现,比如:自己编写一个栈、队列进行处理时也可能出现,一个线程对栈或者队列处理到一半,另一个线程做了一些影响到A线程的操作。

怎么避免ABA?

可以对i的修改加个版本。

Java的原子操作封装类中有版本号的原子操作AtomicStampedReference,除了比对值之外还得比对版本号。

二、Synchronized深入理解

2.1、锁的相关概念

  • 自旋锁: 是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
  • 乐观锁: 假定没有冲突,在修改数据时如果发现数据和之前获取的不一致,则读最新数据,修改后重试修改。
  • 悲观锁: 假定会发生并发冲突,同步所有对数据的相关操作,从读数据就开始上锁。(synchronized就是典型悲观锁)
  • 独享锁(写):给资源加上写锁,线程可以修改资源,其他线程不能再加锁; (单写)
  • 共享锁(读): 给资源加上读锁后只能读不能改,其他线程也只能加读锁,不能加写锁;(多读)
  • 可重入锁、不可重入锁: 线程拿到一把锁之后,可以自由进入同一把锁所同步的其他代码。
  • 公平锁、非公平锁: 争抢锁的顺序,如果是按先来后到,则为公平。

乐观锁适合读多的场景,写场景性能偏低。

可重入锁同步代码块中锁了多次会没有效果,会继续执行到解锁,场景经常用在递归。

不可重入锁,第二次加锁会阻塞。

几种重要的锁实现方式:synchronized、ReentrantLock(可重人锁)、ReentrantReadWriteLock

2.2、synchronized初识

使用方式:

  1. 用于实例方法、静态方法时,隐式指定锁对象。
  2. 用于代码块时,显示指定锁对象。

锁的作用域:对象锁、类锁、分布式锁

特性:可重入、独享(互斥)、悲观锁

锁消除:如果代码中在单线程中使用了锁,如果进入了JIT编译,会自动优化,进行锁消除。

开启锁消除的参数:-XX:+DoEscapeAnalysis -XX:+EliminateLocks

锁粗化:JDK做了锁粗化的优化。比如:

synchronized放在for循环的第一行,在for内层进行加锁,那么每次循环就会去抢锁,耗费资源,进入JIT编译中会被自动优化到for循环外层。

多个synchronized代码块持有相同的锁,顺序排列到一起执行,那么可以粗化为一个。

Note: 有些锁粗化我们自己可直接从代码层面优化完成。synchronized关键字,不仅实现同步,JMM中规定,synchronized要保证可见性(不能够被缓存)。

2.2、synchronized深入

synchronized使用很简单,但是这个关键字具体是怎么实现的呢?JVM怎么知道代码上面加了锁?

若锁占用,线程挂起,释放锁时,如何做到唤醒挂起的线程?

2.2.1、对象头

JVM在堆内存中为一个对象分配一个空间后,应该会用一种方式把这个对象和方法区中存储的类信息(元信息)关联起来,怎么关联呢?

就是在为对象分配的空间中,除了存储字段信息外,还有一个对象头,对象头中就存储了有方法区中元信息的引用,当然除了元信息引用外还有其他关键信息,如下图所示:

  • Class Meta Address:就是方法区中类的元信息引用。 
  • Array Length:如果对象是数组这里会存储数组对象的数组长度。 
  • Mark Word:这里就是重点,继续往下看。
Mark Word:


先解释一下,Mark Word是一块内存区域,里面存的值是上面表格红框部分的某一行。 

具体存的哪一行根据右边的State决定。 

Mark Word这块内存区域多大?根据JDK是32位还是64位,32位内存区域就32位大小,64就64位大小。 

Bitfields(bit域)、tag(状态位)、state(状态)、Hashcode(当前对象的hashcode)、age(垃圾分代回收中的age)

2.2.2、轻量级锁与重量级锁

默认情况下JVM锁会经历:未锁定->偏向锁-> 轻量级锁-> 重量级锁这四个状态

流程分析:

对象创建一开始先是未锁定。

当多个线程要抢这个对象的锁,会用cas操作把tag中的01改成00,让状态变成轻量级锁。

如果某个线程将状态位改成了00,然后就会将00前面的位数改成这个线程引用(Lock record address,这里的地址就是当前线程的地址)

其他没抢到的线程会进行自旋,自旋会有一定的次数阈值,达到一定数量会升级为重量锁。

怎么升级为重量级锁?

先要介绍一个东西Monitor(监视器),monitor也叫做管程,计算机操作系统原理中有提及类似概念。一个对象会有一个对应的monitor。

JVM把状态位由00改为10.

然后把Lock record address改成monitor address。

那么谁获得了锁记录在哪里?记录在Monitor中有个onwer字段,会标记谁获取了锁。

升级为重量级锁是为了解决其他线程不再自旋消耗资源的问题,那么具体怎么解决的?

Monitor中还有一个EntryList(锁池)字段,相当于一个等待队列。

JVM先会把等待线程的引用存入这个队列中。

然后JVM让等待线程挂起。线程就会处于Blocked状态。

然后比如持有锁的线程1,在代码块中调用了wait方法,那么会发生什么情况?

线程1会挂起线程。

onwer会释放掉这个线程的引用,onwer=null

然后这个线程的引用会被放入Monitor的另一个字段waitSet(等待池)中。

其他所有等待线程去抢锁。

假设线程2抢到了锁,如果线程2中执行了notify方法,那么会把waitSet中的一个等待线程拿出来,抢一次锁(onwer),没抢到放入Entrylist中。(notifyAll也一样,只是释放waitSet里所有线程)

然后线程2的代码块执行完后,执行解锁操作,从monitor中退出(exit monitor)。

那么onwer又变成了null。

 

2.2.3、偏向锁

轻量级锁和重量级锁都是多线程的情况下。

还有一种情况,虽然加了synchronized关键字,但是程序是在单线程中执行。

在JDK6 以后,默认已经开启了偏向锁这个优化,通过JVM 参数 -XX:-UseBiasedLocking 来禁用偏向锁若偏向锁开启。

如上图就是偏向锁的Mark Word存储的字段。

如果只有一个线程抢锁,可获取到偏向锁。

如果生成偏向锁后又有线程来抢锁,那么会把锁升级为轻量级锁。

一旦锁升级,偏向锁就会关闭,不会回退为偏向锁。

如果重量级锁中的线程都释放了,那么会退回到未锁定的状态。

2.2.4、锁的升级过程

 

偏向标记第一次有用,出现过争用后就没用了。 -XX:-UseBiasedLocking 禁用使用偏置锁定,

偏向锁,本质就是无锁,如果没有发生过任何多线程争抢锁的情况,JVM认为就是单线程,无需做同步(jvm为了少干活:同步在JVM底层是有很多操作来实现的,如果是没有争抢,就不需要去做同步操作)

三、Lock锁

 

 

 

 

全部评论