JAVA线程安全与锁优化

本文围绕Java线程安全展开,介绍了Java语言中线程安全的分类,包括不可变、绝对线程安全等。阐述了线程安全的实现方法,如互斥同步、非阻塞同步等。还讲解了锁优化措施,如自旋锁、锁消除等,以及偏向锁、轻量级锁、重量级锁的加锁过程和状态转化。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

线程安全

当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行。也不用考虑额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。

JAVA语言中的线程安全

按照由强至弱来排序,我们可以将java语言中各种操作共享数据分为以下5类:不可变、绝对线程安全、相对线程安全、线程兼容、和线程对立(坚决变对象)

1.不可变

不可变的对象一定是线程安全的(谁都改变不了,那肯定是线程安全的),java语言中,如果共享数据是一个基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它是不可变的。如果共享数据是一个对象,那么就需要保证对象的行为不会对其状态产生任何影响才行,如果读者还没想明白这句话,不妨想一想java.lang.String类的对象,它是一个典型的不可变对象,我们调用它的 substring(),replace()和concat()这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象。
保证对象行为不影响自己状态的途径有很多种,其中最简单的就是把对象中带有状态的变量都声明为final,这样在构造函数结束之后,它就是不可变的,例如代码清单13-1中java.lang.Integer 构造函数所示的,它通过将内部状态变量value 定义为final 来保障状态不变。

代码清单13-1 JDK中Integer 类的构造函数



private final int value;

public Integer(int value){
this.value=value;
}

在Java API中符合不可变要求的类型,除了上面提到的String之外,常用的还有枚举类型,以及java.lang.Number 的部分子类,如Long和Double等数值包装类型,BigInteger和BigDecimal等大数据类型;但同为 Number 的子类型的原子类 AtomicInteger和AtomicLong则并非不可变的,读者不妨看看这两个原子类的源码,想一想为什么。

2.绝对线程安全

在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。我们可以通过Java API中一个不是“绝对线程安全”的线程安全类来看看这里的“绝对”是什么意思。
如果说java.util.Vector 是一个线程安全的容器,相信所有的Java程序员对此都不会有异议,因为它的addget和size这类方法都是被synchronized 修饰的,尽管这样效率很低,但确实是安全的。但是,即使它所有的方法都被修饰成同步,也不意味着调用它的时候永远都不再需要同步手段了,请看一下代码清单13-2中的测试代码。

代码清单13-2对Vector线程安全的测试


private static Vector<Integer> vector=new Vector<Integer>();
public static void main(String[]args){
while(true){
for (int i=0;i<10;i++){
vector.add(i);
}
Thread removeThread=new Thread(new Runnable(){
Override
public void run(){
for(int i=O;i< vector.size();i++){
vector.remove(i);
}
});
Thread printThread=new Thread(new Runnable(){
Override
public void run(){
ooler.ei
for(int i=O;i< vector.size();i++){
System.out.println((vector.get(i)));
}
removeThread.start();
printThread.start();
//不要同时产生过多的线程,否则会导致操作系统假死
while(Thread.activecount()>20);
}
}

运行结果如下:
在这里插入图片描述

从这个例子理解绝对线程安全是无需任何同步措施,代码在多线程运行的情况下都不会出现预料之外的情况,那么就是绝对的线程安全

3.相对线程安全

就是我们通常意义上所讲的线程安全,他需要保证对这个对象单独的操作是线程安全的,不需要做额外的保障措施,在极端情况下也需要使用同步措施保证,比如上边提到的vector、HashTable、Collections的synchronizedCollection()方法包装的集合等。

4.线程兼容

对象本身不是现成安全的,可以通过使用同步手段在并发环境中安全使用,大部分的类都属于这种,HashMap等。

5线程对立

无论是否采取同步措施都无法在并发环境中使用的代码,例如Thread的suspend()和resume()方法。

线程安全的方法实现

1.互斥同步

同步是指在多个线程并发访问共享数据时,保证共享数据同一时刻只被一个(或者是一些,使用信号量的时候)线程使用。互斥是因同步是果;互斥是方法,同步是目的

手段1 syncronized关键字

对应的字节码指令是monitorenter 和monitorexit,是可重入的,不会自己把自己锁死;

手段2ReentrantLock

语法层面的,比syncronized多了一些高级功能,主要有以下三项:
1.等待可中断
2.公平锁
3.绑定多个条件
JDK1.6之后二者性能相差不大,之前的版本推荐使用锁

2.非阻塞同步

乐观锁思想。互斥同步主要的问题就是进行线程阻塞和唤醒所带来的性能问题。非阻塞同步需要底层指令集的支持,比较和设置需要是原子性操作,不然也失去了安全性(请仔细思考为什么),非阻塞都是基于CAS方式实现的,CAS涉及到三个值,V表示地址A表示旧值B表示新值,当CAS指令执行时,当且仅当V符合预期值A时才用B的值去更新V的值,并返回旧值。CAS常提到的一个问题是ABA问题,也就是中间被修改过又回到了旧值这种情况,大部分情况下ABA问题不会影响并发程序正确性,如果需要解决改用传统的互斥同步操作比使用带有标记的原子类AtomicStampedReference(阅读源码)更高效;

3无同步方案

如果一个方法根本不涉及到数据共享,那自然就无需使用同步方案了

可重入代码

可重入代码有一些共同的特性,不依赖存储在堆上的公共数据和公共系统资源,用到的状态量都由参数传入、不调用非可重入的方法等。

线程本地存储

如果一个资源可以保证值在一个线程内使用,那么可以使用 ThreadLocal

锁优化

自旋锁与自适应自旋

线程的挂起与恢复操作都需要转入内核状态完成,这些操作给系统并并发性能带来了很大的压力。共享数据的锁定状态一般只会持续很短的一段时间,为了这段时间去把线程挂起和恢复很不值,因此让碰到锁定资源状态的线程进行一个忙循环(自旋),很有可能就获得锁了,这种技术就叫自旋。自旋锁在jdk1.4.2中就引入了,只不过默认是关闭的可以使用-XX:UseSpinning参数来开启,在JDK1.6以后已经是默认开启的了。如果超过自旋次数仍然没有获得锁就将线程挂起。自旋的默认次数默认10次,用户可以使用参数-XX:PreBlockSpin来更改;
在JDK1.6中引入了自适应自旋,意味着自旋的时间不再固定了。如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋很有可能再次成功,进而将自旋时间持续的更长,比如100个循环。另外如果对于某个锁很少自旋成功,那么以后获得这个锁时可能省略掉这个自旋过程。

锁消除

对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除主要判定依据来源于逃逸分析数据的支持,如果判断一段代码在对上的数据永远不会逃逸出去被其他线程访问到,那就无须进行加锁

锁粗化

如果一系列的的连续操作都是对同一个对象反复加锁和解锁,甚至加锁的过程出现在循环体中,频繁的进行互斥和同步操作也会导致不必要的性能损耗。如果虚拟机探测到有这样一串零碎的操作都是对同一个对象加锁,将会把加锁同步的范围扩展到整个操作序列外部,这样只需要加一次锁就可以了。

轻量级锁

轻量级锁是JDK1.6引入的新型锁机制,传统的锁叫做重量级锁,它的本意是 在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能损耗。
要理解轻量级锁,以及后面会讲到的偏向锁的原理和运作过程,必须从HotSpot 虚拟机的对象(对象头部分)的内存布局开始介绍。HotSpot 虚拟机的对象头(Object Header)分为两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode),GC分代年龄(GenerationalGCAge)等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称它为“Mark Word”,它是实现轻量级锁和偏向锁的关键。另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象的话,还会有一个额外的部分用于存储数组长度。
    对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如,在32位的HotSpot 虚拟机中对象未被锁定的状态下,Mark Word的32bit空间中的25bit用于存储对象哈希码(HashCode),4bit用于存储对象分代年龄,2b1t用于存储锁标志位,1bit固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容见表13-1

表13-1

在这里插入图片描述

加锁过程:
    1.在代码进入同步块的时候,如果此对象没有被锁定(锁标志位01),虚拟机将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储所对象目前的Mark Word的对象拷贝(Displaced Mark Word),如图13-3。
    2.虚拟机使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果成功了,那么这个线程就拥有了该对象的锁,并将对象Mark Word的锁标志位更新为00,如图13-4,如果这个更新失败了,检查对象的Mark Word 是否指向当前线程的栈帧,如果是,那就可以直接进入同步块继续执行,否则说明这个对象的锁已经被其他线程抢占了。如果两个以上线程同时抢占一个锁,那轻量级锁就要膨胀为重量级锁,锁标志状态改为“10”,Mark Word中存储的就是指向重量级锁的指针。
在这里插入图片描述

    3.解锁过程通过CAS操作,如果对象的Mark Word仍然指向这线程的所记录,那就使用CAS将对象当前的Mark Word与线程中复制的Displaced Mark Word替换回来。如果替换失败就说明其他线程尝试过获取锁,那就要在释放锁的同时唤醒其他线程。
    轻量级锁提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内是不存在竞争的”

偏向锁

偏向锁也是jdk1.6中引入的一项优化,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序运行性能,连CAS操作都不做了。
它的意思是这个锁偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程将永远不需要再进行同步。
假设虚拟机启用了偏向锁-XX:+UseBiasedLocking,那么当锁对象在第一次被线程获取的时候,虚拟机将把对象头中的标志位设置为“01”。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都不再进行任何同步操作,当另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据所对象目前是否处于被锁定状态,撤销偏向后恢复到未锁定或者轻量级锁定的状态;
偏向锁可以提高有同步但是无竞争的程序性能。可以使用参数-XX-UseBiasedLocking来禁止偏向锁优化。

锁优化之前的加锁操作

重量级锁加锁过程

synchronized的对象锁,标识位10,指针指向的是monitor对象的起始地址。每个对象都存在一个关联的monitor对象,monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,当monitor被某个线程持有后便处于锁定状态。
ObjectMonitor主要数据结构如下

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

等待锁的线程会被封装成ObjectWaiter对象,其中_WaitSet存放处于wait状态的线程,_EntryList用来存放处于等待锁block状态的线程,_owner指向持有ObjectMonitor对象的线程,多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1,如果持有锁的线程调用wait() 方法,那么会释放石油的monitor,owner变为null,count自减1,并将此线程放入到WaitSet集合中等待唤醒。持有锁的线程执行完毕也会释放monitor,并恢复变量值。
在这里插入图片描述

偏向锁,轻量级锁,重量级锁状态的转化

一个对象被创建的时候没有任何锁定,当第一个线程来请求的时候,使用CAS操作将对象的锁状态设置为偏向模式,并设置好获取偏向锁的线程id,之后再需要获取此对象的锁的时候只需要对比线程id是否一致即可。在此线程持有偏向锁的情况下如果来了第二个线程获取锁,看到对象的锁状态是偏向模式,就说明存在竞争,检查持有锁的线程是否存活,如果挂了,则将对象锁状态恢复成无锁,并重新设置新线程的偏向锁;如果原有线程没挂,则马上执行原油线程的操作栈,检查对象的使用情况,如果原有线程仍然要持有偏向锁,则升级为轻量级锁。如果不使用了,则将对象重新恢复成无锁状态,然后重新偏向。
对象在轻量级锁的状态下一般情况会很快释放,等待线程可以在进行几个自旋周期内获取到锁,但是如果自旋后仍未获取到锁,或者又有一个新线程来获取锁,那么轻量级锁膨胀为重量级锁;这是除了获取到锁的线程之外,其他在等待此锁的线程都将进入到阻塞状态。

回顾提问:

1.什么是线程安全
2.java中的线程安全有哪几种,具体含义是什么
3.线程安全的实现方法有哪几种,具体含义是什么
4.java虚拟机锁优化的措施有哪几种,具体是什么
5.轻量级锁的加锁过程,具体解决了什么问题,什么场景下不适合使用
6.偏向锁的加锁过程,具体解决了什么问题,什么场景下不适合使用
7.重量级锁加锁过程
8.偏向锁,轻量级锁,重量级锁的状态是如何转化的




PS:文章写的较早,一直没有整理发布,文章主体内容源自阅读深入理解java虚拟机,部分内容源自阅读网络文章,由于时间久了忘记文章链接,文章与图片如有侵权请联系本人删除


公众号同步更新,欢迎订阅

​​​​ 在这里插入图片描述

2020/5月/15好上传最新版 JavaGuide 目前已经 70k+ Star ,目前已经是所有 Java 类别项目中 Star 数量第二的开源项目了。Star虽然很多,但是价值远远比不上 Dubbo 这些开源项目,希望以后可以多出现一些这样的国产开源项目。国产开源项目!加油!奥利给! 随着越来越多的人参完善这个项目,这个专注 “Java知识总结+面试指南 ” 项目的知识体系和内容的不断完善。JavaGuide 目前包括下面这两部分内容: Java 核心知识总结; 面试方向:面试题、面试经验、备战面试系列文章以及面试真实体验系列文章 内容的庞大让JavaGuide 显的有一点臃肿。所以,我决定将专门为 Java 面试所写的文章以及来自读者投稿的文章整理成 《JavaGuide面试突击版》 系列,同时也为了更加方便大家阅读查阅。起这个名字也犹豫了很久,大家如果有更好的名字的话也可以向我建议。暂时的定位是将其作为 PDF 电子书,并不会像 JavaGuide 提供在线阅读版本。我之前也免费分享过PDF 版本的《Java面试突击》,期间一共更新了 3 个版本,但是由于后面难以同步和订正所以就没有再更新。《JavaGuide面试突击版》 pdf 版由于我工作流程的转变可以有效避免这个问题。 另外,这段时间,向我提这个建议的读者也不是一个两个,我自己当然也有这个感觉。只是自己一直没有抽出时间去做罢了!毕竟这算是一个比较耗费时间的工程。加油!奥利给! 这件事情具体耗费时间的地方是内容的排版优化(为了方便导出PDF生成目录),导出 PDF 我是通过 Typora 来做的。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值