深入剖析Java并发锁机制(九):从偏向锁到自旋锁的全面解析与优化技巧

Lock

锁的状态

  • 锁的4种状态:无锁、偏向锁、轻量级锁和重量级锁
  • 锁状态只能升级不能降级
  • 锁的状态是通过对象监视器在对象头中的字段来表明的

锁的升级流程

  • 偏向锁: 指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价
  • 轻量级锁: 指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其它线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能
  • 重量级锁: 指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其它申请的线程进入阻塞,性能降低

Java对象头

Hotspot虚拟机的对象头主要包括两部分数据(synchronized的锁就是存在Java对象头中):

  • Mark Word(标记字段):默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化
  • Klass Point(类型指针):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例

Monitor

  • Monitor可以理解为一个同步工具或一种同步机制
  • synchronized通过Monitor来实现线程同步
  • Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步
  • 每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁
  • Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表

锁状态升级流程

在这里插入图片描述

  • 偏向锁:通过对比Mark Word解决加锁问题,避免执行CAS操作
  • 轻量级锁:通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能
  • 重量级锁:将除了拥有锁的线程以外的线程都阻塞

优缺点对比

优点缺点适用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距如果线程间存在锁竞争,会带来额外的锁撤销的消耗适用于只有一个线程访问同步块的场景
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度如果始终得不到锁竞争的线程,使用自旋会消耗CPU追求响应时间同步块执行速度非常快
重量级锁线程竞争不使用自旋,不会消耗CPU线程阻塞,响应时间缓慢追求吞吐量,同步块执行速度较长

无锁

  • 无锁是指没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功
  • 修改操作在循环内进行,线程会不断的尝试修改共享资源
  • CAS即是无锁的实现
  • 无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的

偏向锁

指同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。适用于只有一个线程访问同步块场景。

  • 优点:加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距
  • 缺点:如果线程间存在锁竞争,会带来额外的锁撤销的消耗

轻量级锁

指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其它线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。适用于追求响应时间,同步块执行速度非常快。

  • 优点:竞争的线程不会阻塞,提高了程序的响应速度
  • 缺点:如果始终得不到锁竞争的线程使用自旋会消耗CPU

重量级锁

指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其它申请的线程进入阻塞,性能降低。适用于追求吞吐量,同步块执行速度较长。

  • 优点:线程竞争不使用自旋,不会消耗CPU
  • 缺点:线程阻塞,响应时间缓慢

自旋锁(SpinLock)

指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。普通自旋锁是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。其特点如下:

  • 自旋锁适用于锁保护的临界区很小的情况,临界区很小的话,锁占用的时间就很短
  • 阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间
  • 如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长

在这里插入图片描述

简单代码实现:

public class SpinLock {
   private AtomicReference<Thread> owner = new AtomicReference<Thread>();
   public void lock() {
       Thread currentThread = Thread.currentThread();
       // 如果锁未被占用,则设置当前线程为锁的拥有者
       while (owner.compareAndSet(null, currentThread)) {
       }
   }

   public void unlock() {
       Thread currentThread = Thread.currentThread();
       // 只有锁的拥有者才能释放锁
       owner.compareAndSet(currentThread, null);
   }
}

缺点

  • CAS操作需要硬件的配合
  • 保证各个CPU的缓存(L1、L2、L3、跨CPU Socket、主存)的数据一致性,通讯开销很大,在多处理器系统上更严重
  • 没法保证公平性,不保证等待进程/线程按照FIFO顺序获得锁

Ticket Lock

TicketLock即为排队自旋锁。思路:类似银行办业务,先取一个号,然后等待叫号叫到自己。好处:保证FIFO,先取号的肯定先进入。而普通的SpinLock,大家都在转圈,锁释放后谁刚好转到这谁进入。简单的实现:

public class TicketLock {
   private AtomicInteger serviceNum = new AtomicInteger(); // 服务号
   private AtomicInteger ticketNum = new AtomicInteger(); // 排队号

   public int lock() {
       // 首先原子性地获得一个排队号
       int myTicketNum = ticketNum.getAndIncrement();

       // 只要当前服务号不是自己的就不断轮询
       while (serviceNum.get() != myTicketNum) {
       }

       return myTicketNum;
    }

    public void unlock(int myTicket) {
        // 只有当前线程拥有者才能释放锁
        int next = myTicket + 1;
        serviceNum.compareAndSet(myTicket, next);
    }
}

缺点

Ticket Lock 虽然解决了公平性的问题,但是多处理器系统上,每个进程/线程占用的处理器都在读写同一个变量,每次读写操作都必须在多个处理器缓存之间进行缓存同步,这会导致繁重的系统总线和内存的流量,大大降低系统整体的性能。

MCS Lock

MCS SpinLock 是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,直接前驱负责通知其结束自旋,从而极大地减少了不必要的处理器缓存同步的次数,降低了总线和内存的开销。

public class MCSLock {
    public static class MCSNode {
        MCSNode next;
        boolean isLocked = true; // 默认是在等待锁
    }

    volatile MCSNode queue ;// 指向最后一个申请锁的MCSNode
    private static final AtomicReferenceFieldUpdater<MCSLock, MCSNode> UPDATER = 
        AtomicReferenceFieldUpdater.newUpdater(MCSLock.class, MCSNode. class, "queue" );

    public void lock(MCSNode currentThread) {
        MCSNode predecessor = UPDATER.getAndSet(this, currentThread);// step 1
        if (predecessor != null) {
            predecessor.next = currentThread;// step 2
            while (currentThread.isLocked ) {// step 3
            }
        }
    }

    public void unlock(MCSNode currentThread) {
        if ( UPDATER.get( this ) == currentThread) {// 锁拥有者进行释放锁才有意义
            if (currentThread.next == null) {// 检查是否有人排在自己后面
                if (UPDATER.compareAndSet(this, currentThread, null)) {// step 4
                    // compareAndSet返回true表示确实没有人排在自己后面
                    return;
                } else {
                    // 突然有人排在自己后面了,可能还不知道是谁,下面是等待后续者
                    // 这里之所以要忙等是因为:step 1执行完后,step 2可能还没执行完
                    while (currentThread.next == null) { // step 5
                    }
                }
            }

            currentThread.next.isLocked = false;
            currentThread.next = null;// for GC
        }
    }
}

CLH Lock

CLH(Craig, Landin, and Hagersten Locks)锁也是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,它不断轮询前驱的状态,如果发现前驱释放了锁就结束自旋。

public class CLHLock {
    public static class CLHNode {
        private boolean isLocked = true; // 默认是在等待锁
    }

    @SuppressWarnings("unused" )
    private volatile CLHNode tail ;
    private static final AtomicReferenceFieldUpdater<CLHLock, CLHNode> UPDATER = AtomicReferenceFieldUpdater
                  . newUpdater(CLHLock.class, CLHNode .class , "tail" );

    public void lock(CLHNode currentThread) {
        CLHNode preNode = UPDATER.getAndSet( this, currentThread);
        if(preNode != null) {//已有线程占用了锁,进入自旋
            while(preNode.isLocked ) {
            }
        }
    }

    public void unlock(CLHNode currentThread) {
        // 如果队列里只有当前线程,则释放对当前线程的引用(for GC)。
        if (!UPDATER .compareAndSet(this, currentThread, null)) {
            // 还有后续线程
            currentThread. isLocked = false ;// 改变状态,让后续线程结束自旋
        }
    }
}

CLH优势

  • 公平、FIFO、先来后到的顺序进入锁
  • 且没有竞争同一个变量,因为每个线程只要等待自己的前继释放即可

CLH锁与MCS锁的比较

  • 从代码实现来看,CLH比MCS要简单得多
  • 从自旋的条件来看,CLH是在本地变量上自旋,MCS是自旋在其他对象的属性
  • 从链表队列来看,CLH的队列是隐式的,CLHNode并不实际持有下一个节点;MCS的队列是物理存在的
  • CLH锁释放时只需要改变自己的属性,MCS锁释放则需要改变后继节点的属性

常见锁

乐观锁/悲观锁

悲观锁
当线程去操作数据的时候,总认为别的线程会去修改数据,所以它每次拿数据的时候总会上锁,别的线程去拿数据的时候就会阻塞,比如synchronized。

乐观锁
每次去拿数据的时候都认为别人不会修改,更新的时候会判断是别人是否回去更新数据,通过版本来判断,如果数据被修改了就拒绝更新,比如CAS是乐观锁,但严格来说并不是锁,通过原子性来保证数据的同步,比如说数据库的乐观锁,通过版本控制来实现,CAS不会保证线程同步,乐观的认为在数据更新期间没有其他线程影响

小结:悲观锁适合写操作多的场景,乐观锁适合读操作多的场景,乐观锁的吞吐量会比悲观锁大。

公平锁/非公平锁

公平锁
指多个线程按照申请锁的顺序来获取锁,简单来说 如果一个线程组里,能保证每个线程都能拿到锁 比如ReentrantLock(底层是同步队列FIFO: First Input First Output来实现)

非公平锁
获取锁的方式是随机获取的,保证不了每个线程都能拿到锁,也就是存在有线程饿死,一直拿不到锁,比如synchronized、ReentrantLock。

小结:非公平锁性能高于公平锁,更能重复利用CPU的时间。ReentrantLock中可以通过构造方法指定是否为公平锁,默认为非公平锁!synchronized无法指定为公平锁,一直都是非公平锁。

可重入锁/不可重入锁

可重入锁
也叫递归锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁。一个线程获取锁之后再尝试获取锁时会自动获取锁,可重入锁的优点是避免死锁。

不可重入锁
若当前线程执行某个方法已经获取了该锁,那么在方法中尝试再次获取锁时,就会获取不到被阻塞。

小结:可重入锁能一定程度的避免死锁 synchronized、ReentrantLock都是可重入锁。

独占锁/共享锁

独享锁

指锁一次只能被一个线程持有。也叫X锁/排它锁/写锁/独享锁:该锁每一次只能被一个线程所持有,加锁后任何线程试图再次加锁的线程会被阻塞,直到当前线程解锁。例子:如果 线程A 对 data1 加上排他锁后,则其他线程不能再对 data1 加任何类型的锁,获得独享锁的线程即能读数据又能修改数据!

共享锁

指锁一次可以被多个线程持有。也叫S锁/读锁,能查看数据,但无法修改和删除数据的一种锁,加锁后其它用户可以并发读取、查询数据,但不能修改,增加,删除数据,该锁可被多个线程所持有,用于资源数据共享!

小结:ReentrantLock和synchronized都是独享锁,ReadWriteLock的读锁是共享锁,写锁是独享锁。

互斥锁/读写锁

与独享锁/共享锁的概念差不多,是独享锁/共享锁的具体实现。

ReentrantLock和synchronized都是互斥锁,ReadWriteLock是读写锁

自旋锁

自旋锁

  • 一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环,任何时刻最多只能有一个执行单元获得锁。
  • 不会发生线程状态的切换,一直处于用户态,减少了线程上下文切换的消耗,缺点是循环会消耗CPU。

常见的自旋锁:TicketLock,CLHLock,MSCLock

锁优化

偏向锁/轻量级锁

偏向锁

大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了 让线程获得锁的代价更低 而引入了偏向锁,让该线程会自动获取锁,减少不必要的CAS操作。

轻量级锁

对于轻量级锁,其性能提升依据:“对于绝大部分锁,在整个生命周期内都是不会存在竞争的”。轻量级锁的目标:减少无实际竞争情况下,使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。

自旋锁

背景

线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。

定义
自旋锁就是 让该线程等待(即执行一段无意义的循环为自旋)固定的一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。

弊端

自旋可以避免线程切换带来的开销,但它占用了处理器(CPU)的时间。长时间的自旋而不处理任何事,就会浪费资源,所以需要设置自旋等待时间(即自旋次数)。自旋的次数虽然可以通过参数-XX:PreBlockSpin来调整(默认为10次),但固定的自旋次数,会对部分场景(如只需要自旋一两次就可获得锁)造成浪费,因此JDK1.6引入了自适应自旋锁。

适应性自旋锁

所谓自适应就意味着 自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

  • 自旋成功,次数增加:因为虚拟机认为既然上次成功,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多
  • 自旋失败,次数减少:如果对于某个锁,很少有自旋能够成功的,那么在以后要获得这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源

锁消除

为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这时JVM会对这些同步锁进行锁消除,锁消除的依据是 逃逸分析 的数据支持。

锁消除主要是解决我们使用JDK内置API时存在的 隐形加锁操作。如StringBuffer、Vector、HashTable等,StringBuffer的append()方法,Vector的add()方法:

public void vectorTest(){
	Vector<String> vector = new Vector<String>();
	for(int i = 0 ; i < 10 ; i++){
		vector.add(i + "");
	}
	System.out.println(vector);
}

在运行这段代码时,JVM可以明显检测到变量vector没有逃逸出方法vectorTest()之外,所以JVM可以大胆地将vector内部的加锁操作消除。

锁粗化

在使用同步锁的时候,需要让同步块的作用范围尽可能小,仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
在大多数情况下,上述观点是正确的。但如果 一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。锁粗化概念比较好理解,就是 将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁

如上面实例:vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。

分段锁

分段锁其实是一种锁的设计,并不是具体的一种锁,对于 ConcurrentHashMap 而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。

锁细化

  • 减少锁的时间:不需要同步执行的代码,能不放在同步快里面执行就不要放在同步快内,可以让锁尽快释放
  • 减少锁的粒度:将物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁竞争。其思想是用空间来换时间
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

code_未来

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值