【Java并发】synchronized优化

CAS

  • CAS的全称是 Compare And Swap(比较相同再交换),是现代CPU广泛支持的一种对内存中的共享数据进行操作的一种特殊指令
  • 作用:CAS可以将比较和交换转换为原子操作,这个原子操作直接由CPU保证,CAS可以保证共享变量赋值时的原子操作
  • CAS操作依赖三个值:内存中的值V、旧的预估值X、要修改的新值B,如果内存中的值V = 旧的预估值X,就将新值B保存到内存中。

CAS原理

  • Java中的AtomicInteget类就是使用CAS 和 volatile 实现无锁并发的,通过看看AtomicInteget类的getAndIncrement()方法是如何实现来分析CAS原理,以下为JDK部分源码
public class AtomicInteger {
	private static final Unsafe unsafe = Unsafe.getUnsafe();

	// 存储value在内存中的偏移量
	// 当前对象的地址加上这个偏移量得到的就是value值在内存中的地址,从而得到内存中value的值
    private static final long valueOffset;

	// 存储的value值,volatile修饰,保证可见性
    private volatile int value;
    
	public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }
}

public final class Unsafe {

	public final int getAndAddInt(Object paramObject, long paramLong, int paramInt) 
	{
		int i; // 存储内存中的值
		do {
			i = getIntVolatile(paramObject, paramLong); // 这一步就是通过当前对象地址和偏移量得到内存中的value值
		} while (!compareAndSwapInt(paramObject, paramLong, i, i + paramInt));
		return i;
	}
}
  • getAndIncrement()方法最终调用的是Unsafe类中的getAndAddInt()方法,这个方法传 当前AtomicInteger对象,偏移量,以及1。

  • getIntVolatile():通过当前对象地址和偏移量得到内存中的value值,赋值给i

  • compareAndSwapInt()有三个参数:内存中的值V、旧的预估值X、要修改的新值B,当内存中的值v = 旧的预估值X时,就将新值B保存到内存中,同时返回true。

  • 两个线程竞争的场景:

    • 如果两个线程同时调用getAndIncrement()方法
    • 线程1获取到内存中的value值为0,也就是说i = 0
    • 此时切换到线程2,线程2也获取到 i = 0
    • 这时仍然是线程2执行,通过调用compareAndSwapInt()方法,内存中的value值变成了1,并返回true
    • 不满足循环条件,线程2跳出循环,执行完毕
    • 此时切换到线程1,调用compareAndSwapInt()方法是发现,内存中的value值为1,而预估的旧值i是0,两个值不相等,不会更新内存中的value值,返回false
    • 满足循环条件,继续上面的操作,直到赋值成功
  • CAS获取共享变量时,为了保证该变量的可见性,需要加volatile修饰,结合CAS和volatile可以实现无锁并发,适用于竞争不激烈,多核CPU的场景下。

    • 1、因为没有使用synchronized,所以线程不会陷入阻塞,这事效率提升的因素之一。
    • 2、但如果竞争激烈,重试必然频繁发生,反而效率会受影响。

乐观锁和悲观锁

  • 悲观锁:从悲观的角度出发。总是假设最坏的情况,每次去拿数据的时候,都认为别人会修改,所以每次再拿数据的时候都会上锁,这样别人想拿这个数据的时候就会阻塞。
  • 悲观锁性能较差。
  • synchronized我们也称为悲观锁。JDK中的ReentrantLock也是一种悲观锁。
  • 乐观锁:从乐观的角度出发。总是假设最好的情况,每次去拿数据的时候,都认为别人不会修改,就算修改了也没关系,再重试即可。所以不会上锁,但是再更新的时候会判断一下此期间别人有没有去修改这个数据,如果没人修改则更新,有人修改则重试。
  • 乐观锁综合性能较好。
  • CAS这种机制就是乐观锁。

synchronized锁升级过程

  • 无锁 — 偏向锁 —轻量级锁 — 重量级锁
Java对象的布局
  • 一个Java对象包含对象头、实例对象、对齐填充

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

偏向锁
  • 偏向锁时JDK1.6中的重要引进,因为HotSpot作者经过研究实践发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由一个线程多次获得,为了让线程获得锁的代价更低,引进了偏向锁。
  • 偏向锁会偏向于第一个获得它的线程,会在对象头存储锁偏向的线程ID,以后该线程进入和退出同步块时只需要检查是否为偏向锁、锁标志位以及ThreadID即可。
  • 不过一旦出现多个线程竞争时,必须撤销偏向锁,所以撤销偏向锁消耗的性能必须小于之前省下来的CAS原子操作的性能消耗,不然就得不偿失了。
偏向锁原理
  • 当线程第一次访问同步代码块并获取锁时,偏向锁处理流程如下:
    • 虚拟机将会把对象头中的标志位设置为01,偏向锁位设置为1,即偏向锁模式
    • 同时使用CAS操作把获取到这个锁的线程ID记录在对象的Mark Work中
    • 如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步代码块时,虚拟机都可以不再进行任何同步操作
    • 偏向锁的效率高。
偏向锁的撤销
  • 偏向锁的撤销动作必须等待全局安全点
  • 暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态
  • 撤销偏向锁(偏向锁位设置为0),恢复到无锁(标志位为01)或轻量级锁(标志位为00)的状态
偏向锁的好处
  • 偏向锁是只有一个线程执行同步块时进一步提高性能,适用于一个线程反复获得同一个锁的情况,偏向锁可以提高带有同步但无竞争的程序性能。
  • 它同样时一个带有效益权衡性质的优化,也就是说,它并不一定总是对程序运行有立,如果程序中大多数的锁总是被多个不同的线程访问,比如线程池,那偏向锁模式就是多余的
  • 在JDK5中,偏向锁默认是关闭的,而到了JDK6中,偏向锁已经默认开启,但应用程序启动几秒后才会激活,可以使用 -XX:BiasedLockingStartupDelay=0设置偏向锁延迟启动为0(默认是5s),如果确定应用程序中所有锁通常情况下处于竞争状态,可以通过-XX:-UseBiasedLocking=false参数关闭偏向锁
轻量级锁
  • 轻量级锁是JDK1.6之中加入的新型锁机制,它是相当于使用“monitor”的传统锁而言的,传统锁称为重量级锁。
  • 轻量级锁不是为了代替重量级锁的,它只是在某些特定的场景下性能较优
  • 目的:在多线程交替执行同步块的情况下(当前线程执行同步代码块过程中,不会有其他线程去获取锁),引入轻量级锁,尽量避免重量级锁引起的性能消耗。但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁,所以轻量级锁并不是为了代替重量级锁
  • 对于轻量级锁,其性能提升的依据是对于绝大部分的锁,在整个生命周期内都是不会存在竞争的,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。
轻量级锁原理
  • 当关闭偏向锁或多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下:
    • 1、判断当前对象是否处于无锁状态(hashcode 、0、01),如果是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Work的拷贝,将对象的Mark Work复制到栈帧中的Lock Record中,Lock Record中的owner指向当前对象。(锁对象的Mark Work中的信息复制到Lock Record)
    • 2、JVM利用CAS操作尝试将对象的Mark Work更新为指向Lock Record的指针,如果成功表示竞争到锁,则将锁标志位变成00,执行同步操作
    • 3、如果失败,则判断当前对象的Mark Work是否指向当前线程的栈帧,如果是,则表示当前线程已经拥有当前对象的锁,执行同步代码块;否则,只能说明该锁对象被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,标志位变成10,后面等待的线程进入阻塞状态。
轻量级锁的释放
  • 轻量级锁的释放也是通过CAS操作进行的,主要步骤如下:
    • 1、取出在获取轻量级锁保存在LockRecord中MarkWork的数。
    • 2、用CAS操作将取出的数据替换当前对象的MarkWork,如果成功,则说明释放锁成功。
    • 3、如果CAS操作替换失败,说明其他线程尝试释放锁,则轻量级锁需要膨胀为重量级锁
自旋锁
  • monitor实现锁的时候,会阻塞和唤起线程,线程的阻塞和唤起需要CPU从用户态转换为内核态,频繁的阻塞和唤起对CPU来说是一件负担很重的工作,对CPU开销很大,切换成本很高,给系统的并发性能带来很大的压力。
  • 同时,虚拟机开发团队注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间阻塞和唤起线程并不值得,如果物理机上有一个以上的处理器,能让两个或以上的线程同时执行,我们就让后面的线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。
  • 为了让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
  • 自旋锁在JDK1.4.2中已经引入,但默认是关闭的,在JDK1.6中,更改为默认开启
  • 自旋等待不能代替阻塞,它对处理器数量有要求,且自旋等待本身虽然避免了线程切换,但他是要占用CPU时间,因此,如果锁被占用时间很短,自旋等待的效果很好,反之会带来性能上的浪费。因此,自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应该使用传统方式去挂起线程了。
  • 自旋次数默认是10次,可以用参数-XX:ProBlockSpin来更改
适应性自旋锁
  • 在JDK1.6中引入了自适应的自旋锁。
  • 自适应意味着自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者状态来决定
  • 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么JVM就会任务这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间
  • 如果对于某个锁,自旋很少成功获得过,那以后要获取这个锁时将可能省略掉自旋过程,以避免浪费CPU资源
锁消除
  • 锁削除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行削除。
  • 锁削除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。
  • 逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。
public String contactString(String s1, String s2, String s2) {
	StringBuffer stringBuffer = new StringBuffer();
	return stringBuffer.append(s1).append(s2).append(s3).toString();
}
  • 上面那段代码,StringBuffer的append()方法是同步方法,3次append()方法相当于加了3次锁。
  • 同步方法的锁对象是this,也就是上面new出来的StringBuffer对象stringBuffer。
  • 当两个线程调用contactString()方法时,两个线程的锁对象是不同的,也就是说,某种程度上来讲,这两个线程是不存在竞争关系的,此时加3次锁是毫无意义的。
  • 实际上,根据逃逸分析,可以看到stringBuffer的作用域仅限在contactString()方法内,也就是说stringBuffer的所有引用永远不会“逃逸”到concatString()方法之外,其他线程无法访问到它,所以这里虽然有锁,但是可以被安全地削除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了
锁粗化
  • 通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽可能短,但是大某些情况下,一个程序对同一个锁不间断、高频地请求、同步与释放,会消耗掉一定的系统资源,因为锁的讲求、同步与释放本身会带来性能损耗,这样高频的锁请求就反而不利于系统性能的优化了,虽然单次同步操作的时间可能很短。
  • JVM会探测到一连串细小的操作都使用同一个对象加锁,将同步代码块的范围放大,放到这串操作的外面,这样就只需要加1次锁就可以了。
  • 锁粗化就是告诉我们任何事情都有个度,有些情况下我们反而希望把很多次锁的请求合并成一个请求,以降低短时间内大量锁请求、同步、释放带来的性能损耗。
for (int i = 0; i < 100; i++) {
	synchronized (obj) {
		// 做一些能很快完成的操作
	}
}
  • 锁粗化后
synchronized (obj) {
	for (int i = 0; i < 100; i++) {
		// 做一些能很快完成的操作
	}
}

日常代码中对synchronized的优化

减少synchronized的范围
  • 同步代码块中尽量短,减少同步代码块中代码执行的时间,减少锁的竞争
  • 这样轻量级锁或自旋锁就能保证线程安全,避免锁升级为重量级锁
降低synchronized锁的粒度
  • 将一个锁拆分为多个锁,非相关的代码块使用两个不同的锁对象上锁
    • Hashtable:get/put/remove操作使用的是同步方法,锁对象是当前对象this,锁定整个哈希表,一个操作正在进行时,其他操作也同时锁定,效率低下。
    • ConcurrentHashMap:get无锁,put操作使用同步代码块,锁对象是当前桶的第一个元素,局部锁定,只锁定桶,对当前元素锁定时,对其他元素不锁定。
读写分离
  • 读取时不加锁,写入和删除时加锁
  • ConcurrentHashMap,CopyOnWriteArrayList和CopyOnWriteSet

锁升级过程图解

在这里插入图片描述

超全资料
java 中的锁 – 偏向锁、轻量级锁、自旋锁、重量级锁

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值