锁优化

本文深入探讨Java中的锁优化技术,包括自旋锁、锁粗化和锁消除等概念。通过银行办理业务的类比,形象地解释了这些技术如何帮助改善多线程环境下程序的性能。

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

1.回顾

通过前面对虚拟机和线程的学习:

  1. 同步方法通过ACC_SYNCHRONIZED关键字隐式的对方法进行加锁。当线程要执行的方法被标注上ACC_SYNCHRONIZED时,需要先获得锁才能执行该方法。《synchronized
  2. 同步代码块通过monitorentermonitorexit执行来进行加锁。当线程执行到monitorenter的时候要先获得所锁,才能执行后面的方法。当线程执行到monitorexit的时候则要释放锁。《Java的对象头》《synchronized
  3. 在HotSpot虚拟机中,使用oop-klass模型来表示对象。每一个Java类,在被JVM加载的时候,JVM会给这个类创建一个instanceKlass,保存在方法区,用来在JVM层表示该Java类。当我们在Java代码中,使用new创建一个对象的时候,JVM会创建一个instanceOopDesc对象,这个对象中包含了对象头以及实例数据。《Java的对象头
  4. 在对象头中主要包含了GC分代年龄、锁状态标记、哈希码、epoch等信息。对象的状态一共有五种,分别是无锁态、轻量级锁、重量级锁、GC标记和偏向锁。《Java的对象头

在JDK1.6之前,synchronized的实现才会直接调用ObjectMonitorenterexit,这种锁被称之为重量级锁。

高效并发是从JDK 1.5 到 JDK 1.6的一个重要改进,HotSpot虚拟机开发团队在这个版本中花费了很大的精力去对Java中的锁进行优化,如适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等。这些技术都是为了在线程之间更高效的共享数据,以及解决竞争问题。这里简单说明一下,本文要介绍的这几个概念,以及后面要介绍的轻量级锁和偏向锁,其实对于使用他的开发者来说是屏蔽掉了的,也就是说,作为一个Java开发,你只需要知道你想在加锁的时候使用synchronized就可以了,具体的锁的优化是虚拟机根据竞争情况自行决定的。

也就是说,在JDK 1.5 以后,我们即将介绍的这些概念,都被封装在synchronized中了。

2.线程状态

线程和线程的状态。锁和线程的关系是怎样的呢,举个简单的例子。

比如,你今天要去银行办业务,你到了银行之后,要先取一个号,然后你坐在休息区等待叫号,过段时间,广播叫
到你的号码之后,会告诉你去哪个柜台办理业务,这时,你拿着你手里的号码,去到对应的柜台,找相应的柜员开
始办理业务。当你办理业务的时候,这个柜台和柜台后面的柜员只能为你自己服务。当你办完业务离开之后,广播
再喊其他的顾客前来办理业务。

这个例子中,每个顾客是一个线程。 
柜台前面的那把椅子,就是锁。
柜台后面的柜员,就是共享资源。 
你发现无法直接办理业务,要取号等待的过程叫做阻塞。
当你听到叫你的号码的时候,你起身去办业务,这就是唤醒。 
当你坐在椅子上开始办理业务的时候,你就获得锁。 
当你办完业务离开的时候,你就释放锁。

对于线程来说,一共有五种状态,分别为:初始状态(New) 、就绪状态(Runnable) 、运行状态(Running) 、阻塞状态(Blocked) 和死亡状态(Dead) 。

2.自旋锁

在《Java的对象头》文章中,我们介绍的synchronized的实现方式中使用Monitor进行加锁,这是一种互斥锁,为了表示他对性能的影响我们称之为重量级锁。

这种互斥锁在互斥同步上对性能的影响很大,Java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统的帮忙,这就要从用户态转换到内核态,因此状态转换需要花费很多的处理器时间。

就像去银行办业务的例子,当你来到银行,发现柜台前面都有人的时候,你需要取一个号,然后再去等待区等待,一直等待被叫号。这个过程是比较浪费时间的,那么有没有什么办法改进呢?

有一种比较好的设计,那就是银行提供自动取款机,当你去银行取款的时候,你不需要取号,不需要去休息区等待叫号,你只需要找到一台取款机,排在其他人后面等待取款就行了。

之所以能这样做,是因为取款的这个过程相比较之下是比较节省时间的。如果所有人去银行都只取款,或者办理业务的时间都很短的话,那也就可以不需要取号,不需要去单独的休息区,不需要听叫号,也不需要再跑到对应的柜台了。

而,在程序中,Java虚拟机的开发工程师们在分析过大量数据后发现:共享数据的锁定状态一般只会持续很短的一段时间,为了这段时间去挂起和恢复线程其实并不值得。

如果物理机上有多个处理器,可以让多个线程同时执行的话。我们就可以让后面来的线程“稍微等一下”,但是并不放弃处理器的执行时间,看看持有锁的线程会不会很快释放锁。这个“稍微等一下”的过程就是自旋。

自旋锁在JDK 1.4中已经引入,在JDK 1.6中默认开启。

很多人在对于自旋锁的概念不清楚的时候可能会有以下疑问:这么听上去,自旋锁好像和阻塞锁没啥区别,反正都是等着嘛。

对于去银行取钱的你来说,站在取款机面前等待和去休息区等待叫号有一个很大的区别:

  • 那就是如果你在休息区等待,这段时间你什么都不需要管,随意做自己的事情,等着被唤醒就行了。

  • 如果你在取款机面前等待,那么你需要时刻关注自己前面还有没有人,因为没人会唤醒你。

  • 很明显,这种直接去取款机前面排队取款的效率是比较高。

所以呢,自旋锁和阻塞锁最大的区别就是,到底要不要放弃处理器的执行时间。对于阻塞锁和自旋锁来说,都是要等待获得共享资源。但是阻塞锁是放弃了CPU时间,进入了等待区,等待被唤醒。而自旋锁是一直“自旋”在那里,时刻的检查共享资源是否可以被访问。

由于自旋锁只是将当前线程不停地执行循环体,不进行线程状态的改变,所以响应速度更快。但当线程数不停增加时,性能下降明显,因为每个线程都需要执行,占用CPU时间。如果线程竞争不激烈,并且保持锁的时间段。适合使用自旋锁。

3.同步省略

除了自旋锁之后,JDK中还有一种锁的优化被称之为锁消除。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。详细请见《对象不一定都是在堆上分配内存的》一文中

4.锁粗化

很多人都知道,在代码中,需要加锁的时候,我们提倡尽量减小锁的粒度,这样可以避免不必要的阻塞。

这也是很多人原因是用同步代码块来代替同步方法的原因,因为往往他的粒度会更小一些,这其实是很有道理的。

还是我们去银行柜台办业务,最高效的方式是你坐在柜台前面的时候,只办和银行相关的事情。如果这个时候,你拿出手机,接打几个电话,问朋友要往哪个账户里面打钱,这就很浪费时间了。最好的做法肯定是提前准备好相关资料,在办理业务时直接办理就好了。

加锁也一样,把无关的准备工作放到锁外面,锁内部只处理和并发相关的内容。这样有助于提高效率。

那么,这和锁粗化有什么关系呢?可以说,大部分情况下,减小锁的粒度是很正确的做法,只有一种特殊的情况下,会发生一种叫做锁粗化的优化。

就像你去银行办业务,你为了减少每次办理业务的时间,你把要办的五个业务分成五次去办理,这反而适得其反了。因为这平白的增加了很多你重新取号、排队、被唤醒的时间。

如果在一段代码中连续的对同一个对象反复加锁解锁,其实是相对耗费资源的,这种情况可以适当放宽加锁的范围,减少性能消耗。

当JIT发现一系列连续的操作都对同一个对象反复加锁和解锁,甚至加锁操作出现在循环体中的时候,会将加锁同步的范围扩散(粗化)到整个操作序列的外部。

如以下代码:

for(int i=0;i<100000;i++){  
   synchronized(this){  
       do();  
}

会被粗化成:

synchronized(this){  
   for(int i=0;i<100000;i++){  
       do();  
}

这其实和我们要求的减小锁粒度并不冲突。减小锁粒度强调的是不要在银行柜台前做准备工作以及和办理业务无关的事情。而锁粗化建议的是,同一个人,要办理多个业务的时候,可以在同一个窗口一次性办完,而不是多次取号多次办理。

5.总结

自Java 6/Java 7开始,Java虚拟机对内部锁的实现进行了一些优化。这些优化主要包括锁消除(Lock Elision)、锁粗化(Lock Coarsening)、偏向锁(Biased Locking)以及适应性自旋锁(Adaptive Locking)。这些优化仅在Java虚拟机server模式下起作用(即运行Java程序时我们可能需要在命令行中指定Java虚拟机参数“-server”以开启这些优化)。

本文主要介绍了自旋锁、锁粗化和锁消除的概念。在JIT编译过程中,虚拟机会根据情况使用这三种技术对锁进行优化,目的是减少锁的竞争,提升性能。

当你来到银行,办理业务的时候,你想取钱,银行工作人员了解到你要取钱之后,让你你直接站在取款机前面排队等待,并且告诉你自己时刻关注前面的排队状况。这就叫自旋。

当你来到银行,办理业务的时候,银行工作人员告诉你,由于现在办理业务的人很少,让你直接到1号柜台去办理业务。这就叫锁消除。

当你来到银行,办理业务的时候,你取了10个号,准备进行十次排队进行转账,银行工作人员了解情况之后,但是你在一次办理业务过程中进行了10次转账,办理了所有业务。这就叫锁粗化。

优化是指在多线程编程中,通过改进的机制和使用方式来提高程序的性能和并发能力。synchronized关键字是Java中最常用的机制之一,它可以保证同一时间只有一个线程可以进入被synchronized修饰的代码块。下面是一些synchronized优化的方法: 1. 减小的粒度:如果在一个方法中有多个synchronized代码块,可以考虑将这些代码块拆分成多个方法,以减小的粒度。这样可以使得多个线程可以并发执行不同的代码块,提高程序的并发性能。 2. 使用局部变量替代成员变量:在使用synchronized关键字时,尽量使用局部变量而不是成员变量。因为成员变量的访问需要通过对象实例来进行,而局部变量的访问是线程私有的,不需要加。 3. 使用同步代码块代替同步方法:在某些情况下,使用同步代码块比使用同步方法更加灵活。同步代码块可以指定的粒度,只对需要同步的代码进行加,而不是整个方法。 4. 使用volatile关键字:volatile关键字可以保证变量的可见性和禁止指令重排序,可以在一定程度上替代synchronized关键字。但是需要注意,volatile关键字只能保证单个变量的原子性,不能保证多个操作的原子性。 5. 使用Lock接口:Java提供了Lock接口及其实现类ReentrantLock,相比于synchronized关键字,Lock接口提供了更加灵活的机制。可以手动控制的获取和释放,可以实现公平和非公平,并且支持多个条件变量。 6. 使用读写:如果在多线程环境下,读操作远远多于写操作,可以考虑使用读写ReadWriteLock来提高程序的并发性能。读写允许多个线程同时读取共享数据,但只允许一个线程写入共享数据。 7. 使用并发集合类:Java提供了一些并发集合类,ConcurrentHashMap、ConcurrentLinkedQueue等,它们内部使用了一些优化的技术,可以提高多线程环境下的并发性能。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值