java虚拟机系列(十一)线程安全与锁优化

本文详细解析Java中线程安全的概念,包括不可变、绝对、相对线程安全及线程兼容和对立的特性。深入探讨互斥同步、非阻塞同步与无同步方案,以及JDK1.5至1.6的锁优化技术,如自旋锁、锁消除、锁粗化、轻量级锁和偏向锁,旨在提高多线程环境下的程序执行效率。

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

java虚拟机系列(十一)线程安全与锁优化

一、线程安全

《java Concurrency In Practive》的作者Brian Goetz对“线程安全”有一个比较恰当的定义:“当多个线程访问同一个对象,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的”。

如果一个对象可以安全的被多个线程同时使用,那它就是线程安全的。

1.1 java语言中的线程安全

线程安全是限定于多个线程之间存在共享数据访问这个为前提的。

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

1.1.1 不可变

在java语言中不可变对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要采取任何的线程安全保障措施。

如果共享数据是基本数据类型,那么在定义时使用final关键字修饰它就可以保证它是不可变的。如果是共享数据是一个对象,那就需要保证对象的行为不会对其状态产生任何影响才行,java.lang.String就是一个典型的不可变对象。保证对象行为不影响自己状态的最简单方式就是把对象中带有状态的变量都修饰为final,这样构造函数结束之后,它就是不可变的。

1.1.2 绝对线程安全

一个类要达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”通常需要付出很大的,甚至有时候的不切实际的代价。在java API中标注自己是线程安全的类,大多数都不是绝对的线程安全(都是相对线程安全)。

1.1.3 相对线程安全

相对线程安全就是我们通俗意义上的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。(如hashtable,Vector等)

1.1.4 线程兼容

线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确的使用同步手段来保证对象在并发环境中可以安全的使用,我们平常说一个类不是线程安全的,绝大多数时候指的是这种情况。java API中大部分的类都是属于线程兼容的,如ArrayList和HashMap。

1.1.5 线程独立

线程对立是指无论无论调用端是否采取了同步措施,都无法在多线程环境并发使用的代码。由于java语言天生就具备多线程特性,线程对这个排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。

常见的线程对立操作有Thread类的suspend()和resume()方法(并发同时使用会造成死锁)。

1.2 线程安全的实现方法

1.2.1 互斥同步

互斥同步时常见的一种并发正确性保障手段。同步是指在多线程并发访问共享数据时,保证共享数据在同一时刻只能被一个线程(或者是一些,使用信号量的时候)使用。而互斥是实现同步的一种手段,临界区、互斥量、信号量都是主要互斥的实现方式。互斥是因,同步是果;互斥是方法,同步是目的。

java中最基本的互斥同步手段就是synchronized关键字,synchronized关键字经过编译后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。

如果java程序中synchronized明确指明了对象参数,那就是这个对象的reference;如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。

根据虚拟机规范的要求,在执行monitorenter指令时,首先要尝试获取对象的锁。如果这个对象没有被锁定,或者当前对象已经这个对象的锁,把锁的计数器加 1,相应的,在执行monitorexit指令时将计数器减 1,当计数器为 0 时,锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。

synchronized同步块对同一条线程来说是可重入的,同步块在执行完之前会阻塞后面其它线程的进入。上一篇文章说过java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙,这就需要从用户态转换到核心态,而状态转换会耗费很多的处理器时间。

对于戴代码简单的同步块(如被synchronized修饰的getter()和setter()方法),状态转换消耗的时间有可能比用户代码执行的时间还要长。所以synchronized是java语言中一个重量级的操作,所以非必要的情况下不会使用这种操作。而虚拟机本身也进行了一些优化,比如在通知操作系统阻塞线程之前加入一段自旋等待过程,避免频繁切入到核心态之中。

除了synchronized外,我们还可以使用java.util.concurrent包中的重入锁(ReentrantLock)来实现同步,基本用法上synchronized和ReentrantLock很相似,都具备重入特性,只是代码写法有点区别,一个表现为API层面的互斥锁(lock()和unlock()方法配合try/finally语句块来完成),另一个表现为原生语法层面的互斥锁。不过相比于synchronized,ReentrantLock增加了一些高级功能,主要有以下3项:等待可中断、可实现公平锁、锁可绑定多个条件(可选择性通知)。

  • 等待可中断: 是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待(可通过lock.lockInterruptibly()来实现这个机制),改为处理其它事情,可中断特性对处理执行时间非常长的同步块很有帮助。
  • 公平锁: 是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁时非公平的,ReentrantLock默认情况下也是非公平的,但可以通过ReentrantLock(boolean fair)构造函数来指定是否公平。
  • 锁可以绑定多个条件(可选择性通知): synchronized关键字与wait()和notify()/notifyall()方法结合可以实现等待通/知机制,ReentrantLock自然也可实现,但是需要借助于Condition接口与newCondition()方法。Condition是1.5之后才有,它具有很好的灵活性,比如可以实现多路通知功能,也就是一个Lock对象可以创建多个Condition实例(即对象监视器)
    ,线程对象可以注册在指定的Condition中,从而可以进行有选择的线程通知,在调度线程上更加灵活。

    在使用notify()/notifyall()方法进行线程通知时,被通知的线程是由JVM选择的,使用类ReentrantLock结合Condition实例可以实现“选择性通知”,这个功能非常重要,而且是Condition接口默认提供的。而synchronized就相当于整个Lock对象中只有一个Condition实例,所有的线程都注册在它身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程,这样将会造成很大的效率问题,而Condition实例的signalAll()方法只会唤醒注册在该Condition实例中所有的等待线程。

如果要使用上述三项功能,ReentrantLock是很好的选择。

JDK1.5中,多线程环境下synchronized的吞吐量下降得很厉害,而ReentrantLock较为平稳。

JDK1.6中,多线程环境下synchronized与ReentrantLock的性能基本持平了,由此可以看出虚拟机在未来的改进还是更偏向于原生synchronized,所以在能满足需求的情况下尽量使用原生synchronized来实现。

1.2.2 非阻塞同步

互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。从处理问题上来说,互斥同步属于悲观并发策略,总是认为只要不去做正确的同步措施(例如加锁),那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。

随着硬件指令集的发展,我们有了另外一个选择:基于冲突检测的乐观并发策略,通俗的说,就是先进行操作,如果没有其它线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其它补偿措施(常见的补偿措施就是不断重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作也称为非阻塞同步。

硬件需要保证操作和冲突检测这两个操作具备原子性,硬件保证一个从语义上看起来需要多次操作的行为只通过一条处理器指令就能完成,这类指令常用的有:

  • 测试并设置(Test-and-Set)
  • 获取并增加(Fetch-and-Increment)
  • 交换(swap)
  • 比较并交换(Compare-and-Swap,简称CAS)
  • 加载连接/条件存储(Load-Linked/Store-Conditional,下文称LL/SC)

后两条指令是现代处理器新增的,这两条指令的功能和目的类似。

CAS指令需要3个操作数,分别是内存位置(java中可以简单理解为变量的内存地址,用V表示),旧的预期值(用A表示)和新值(用B表示)。CAS指令执行时,当且仅当V符合旧预期值A时,处理器用新值B更新V值,否则就不执行更新,但是无论是否更新了V值,都会返回V的旧值,上述的处理过程是一个原子操作。

JDK1.5之后,java程序才可以使用CAS操作,该操作由sun.misc.Unsafe类里面的compareAndSwapInt()和compareAndSwapLong()等几个native方法包装提供,虚拟机在内部对这些方法做了底层处理,即时编译出来的结果就是一条平台相关的处理器指令,没有方法调用过程。

Unsafe类不是提供给用户程序调用的类(Unsafe.getUnsafe限制了只有启动类加载器加载的Class才能访问它),因此,如果不采用反射手段,我们只能使用其它的java API来间接使用它,如J.U.C包里面的整数原子类,其中compareAndSet()和incrementAndGet()等方法都使用Unsafe类的CAS操作,这些方法都具备原子性,是线程安全的方法。

incrementAndGet()方法在一个无限循环中,不断尝试将一个比当前值大于1的新值赋给自己。如果失败了,那说明在执行“获取-设置”操作的时候值已经有了修改,于是再次循环进行下一次操作,直到设置成功为止。

尽管CAS看起来很完美,但是还是会存在逻辑漏洞:即一个变量V初次读取的时候为A值,并且在准备赋值的时候检查到它仍为A值,我们就能说它的值没有被人家改变吗?如果这个期间它的值先被改为B再被改为A,那么CAS操作就会误认为它没有被改变过。这个漏洞称为CAS操作的“ABA”问题。J.U.C包为了解决这个问题,提供了一个带有标记的原子引用类AtomicStampedReference,它可以通过控制变量值的版本来控制CAS值的准确性。不过目前来说,这个类比较“鸡肋”,大部分情况下ABA问题不会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会原子类更高效。

1.2.3 无同步方案

要保证线程安全并一定要保证线程同步,两种没有因果关系。同步只是保证共享数据争用时的正确性手段,如果一个方法本来就不涉及共享数据,那它自然就无需任何同步措施去保证正确性,因此有一些代码天生就是线程安全的。

可重入代码

也叫纯代码,可以在代码执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会发生任何错误。

相对于线程安全来说,可重入性是更基本的特征,它可以保证线程安全,即所有的可重入代码都是线程安全的,但并非所有线程安全的代码都是可重入的。

线程本地存储

如果一段代码中所需要的数据必须与其它代码共享,那就看看这些共享数据的代码是否能保证在同一线程中执行?如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程安全。

java语言中的java.lang.ThreadLocal来实现线程本地存储的功能。每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个Thread对象存储了一组以Thread.threadLocalHashCode为键,以本地线程变量为值的K-V对,Thread对象就是当前线程ThreadLocalMap的访问入口,每一个ThreadLoca对象都包含一个独立无二的threadLocalHashCode值,使用这个值(K值)就可以在线程K-V值对中找回对应的本地线程变量。

二、锁优化

高效并发是JDK1.5到JDK1.6的一个重要改进,也就是实现了各种锁优化技术,如适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等,这些技都是为了在线程之间更高效的共享数据,以及解决竞争问题,从而提高程序的执行效率。

2.1 自旋锁与自适应自旋

当一个物理机器有一个以上的处理器时,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器执行时间(而是让线程执行自旋),看看持有锁的线程是否会很快就释放锁。 这项技术就是所谓的自旋锁。

自旋锁不能代替阻塞,自旋虽然避免了线程切换的开销,但它是要占用处理器时间的,因此,如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间很长,那么自旋的线程只会白白耗费处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。

因此,自旋锁等待的世界必须有一定的限度,如果自旋超过了限定次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了,自旋锁的默认值是10,可以通过-XX:PreBlockSpin来更改。

JDK1.6引入了自适应自旋锁,意味着自旋时间不再固定,而是由前一次在同一锁上的自旋时间及锁的拥有者的状态来决定。如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。

2.2 锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。 锁消除的主要判断依据来源于逃逸分析的支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其它线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。

2.3 锁粗化

原则上,在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样为了使得需要同步的操作尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。

大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁解锁,甚至加锁解锁操作都是出现在循环体中,那即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能损耗。

因此,如果虚拟机探测到有一串零碎操作都对同一个对象加锁,将会把加锁同步范围扩展(粗化)到整个操作序列的外部

2.4 轻量级锁

JDK1.6中新加入的新型锁优化机制,它的本意是在没有多线程竞争的前提下,减少 传统重量级锁使用操作系统互斥量产生的 性能消耗,使用CAS操作去消除同步使用的互斥量。 它名字中的“轻量级”是相对于使用操作系统互斥量来实现的传统锁(重量级锁)而言的,轻量级锁是并不是替代重量级锁的,

要理解轻量级锁,以及偏向锁的原理和运作过程,必须从HotSpot虚拟机的对象(对象头部分)的内存布局开始介绍。HotSpot虚拟机的对象头(Object Header)分为两部分信息:

  • 第一部分用于存储对象的运行时数据(如哈希码、GC年龄)等,这部分数据,官方称为“Mark Word”(标记词),它是实现轻量级锁和偏向锁的关键,它是实现偏向锁和轻量级锁的关键。
  • 第二部分用于存储指向方法区对象类型的指针,如果是数组对象,还会有一个额外的部分存放数组长度

对象头信息是与对象自定义的数据无关的额外成本,为了提高虚拟机空间效率,
Mark Word被设置成了一个非固定的数据结构以便在极小的空间中存储尽量多的信息,它会根据对象的不同状态存储的不同的信息(复用存储空间)。例如:
在32位的HotSpot虚拟机中对象未被锁定的状态下,Mark Word的32bit空间中25bit用于存储对象哈希码,4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定为0。在不同的状态(未锁定、轻量级锁定、重量级锁定、GC标记、可偏向)下,Mark Word中存储的信息不同,如下表所示。

在这里插入图片描述

在代码进入同步块时,对象还没有被锁定(锁标记位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,这时候线程堆栈与对象头的状态如下图所示。

在这里插入图片描述

然后,虚拟机使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果更新动作成功了,那么线程就拥有该对象的锁,并且对象的Mark Word的锁标记位将转变为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如下图所示。
在这里插入图片描述

如果更新失败了,虚拟机会首先检查对象的Mark Word是否指向当前线程的栈帧,如果只说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其它线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标记为变为“10”,Mark Word存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。

上述的轻量级锁的加解锁都是通过CAS操作来进行的。在大部分情况下,轻量级锁能够提升程序同步性能,因为对于绝大部分的锁,在整个同步周期内都是不存在竞争的。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,如果存在锁竞争,轻量级锁会比传统重量级锁更慢,因为除了互斥量开销外,还额外发生了CAS操作的开销。

2.5 偏向锁

偏向锁也是JDK1.6引入的一项锁优化,目的是消除数据在无竞争情况下的同步原语(无竞争情况下把整个同步都消除掉,连CAS操作都不做了),进一步提高程序的运行性能

偏向锁的“偏”是指这个锁会偏向于第一个获得它的线程,如果在接下里的执行过程中,该锁没有被其它线程获取,则持有偏向锁的线程将永远不需要再进行同步。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值