JUC并发编程01——谈谈锁机制:轻量级锁、重量级锁、偏向锁、锁消除与锁优化

2022-10-26 17:57:48 浏览数 (2)

1.为什么要有并发编程

计算机的cpu与I/O的效率并不是完全一致的,CPU的处理速度快时,在进行I/O操作时,可能会导致CPU空闲的状态,为了最打程度的利用cpu的资源,开发人员创造了并发编程,进程通过轮换可以最大程度的利用cpu的资源,同时给用户进程在同步执行的错觉。但是进程之间并不会共享数据,同时上下文的切换也比较耗时,线程横空出世,同一个进程中的不同线程之间内存共享一片内存区域,线程上下文切换也很轻量级。juc是java官方提供的线程操作的jar包,可以尽可能的降低我们并发编程的难度。

2.锁机制

代码语言:javascript复制
public class Demo2 {

    public static void main(String[] args) {
        synchronized (Demo2.class) {
            
        }
    }
}

使用idea中的插件jclasslib将其class文件反编译下。

代码语言:javascript复制
 0 ldc #2 <com/wangzhou/Demo2>
 2 dup
 3 astore_1
 4 monitorenter
 5 aload_1
 6 monitorexit
 7 goto 15 ( 8)
10 astore_2
11 aload_1
12 monitorexit
13 aload_2
14 athrow
15 return

其执行逻辑是,先会进入monitorenter将锁占有,然后执行代码逻辑,再使用第六行的monitorexit去时放锁。不过,如果执行代码逻辑的过程中出现了异常,就会执行第12行时放锁,并抛出异常。可以参考下面程序流程图进行理解。

还有一个问题,为什么需要在synchronized中写一个Demo02.class呢?其实这是因为每个对象都有一个monitor与之关联,这里我们使用的锁就是存储在了Demo02的monitor,它存储在对象头中。我们知道java的对象存储在堆内存中,而每个对象的存储都会有对象头的空间区域,如果这个对象使用了锁,对象头中就会存储这个锁的信息了。

3.重量级锁

JDK6以前,java中的synchronized锁会映射到操作系统的Lock来实现,而java的线程则映射到操作系统的线程,切换成本较高。

我们之前提到过,每个对象都会有一个monitor与之关联。其底层实现是c 中的ObjectMonitor,每个等待锁的线程都会被封装成为一个ObjectWaiter对象。

每个ObjectWaiter首先都会进入Entry Set进行等待,当获取到monitor后,会进入The Ownner,将 ObjectMonitor中的线程置未当前线程,count变量自增1.当线程调用wait方法时,会暂时释放当前的锁,进入Wait Set等待,并且将ObjectMonitor中的owner置成null,将count自减1.当当前线程执行完时,也将会释放当前的monitor并复位变量的值。以便其它线程能够获取锁对象。

这样的设计看似合理,不过一般应用中的线程占用同步代码块的时间并不是很长,我们没有必要将竞争中的线程挂起又唤醒。并且目前的cpu都是多核的,jdk1.4.2提供了一种新的解决方案:自旋锁,在jdk6以后,自旋锁默认开启。

自旋锁的逻辑是,竞争中的线程不必被挂起,而是不断循环的检测能否获取锁资源,一旦能够获取锁资源,就马上拿到锁进行运行。由于线程占用锁的时间不是太常,这种循环检测不用进行太多次,可以很快的拿到锁资源。从而避免了挂起、唤醒线程所产生的额外开销。但是由于线程并没有被挂起,所以其实是在消耗cpu的资源的,当每个线程占用锁的时间较长时,使用自旋锁就会比较消耗cpu的资源。因此,自旋锁的等待是有限制的,在jdk6之前,自旋锁默认为10次,当超过10次仍然没有获取到锁,就会进入到重量级锁的机制。

在jdk6以后,自旋锁机制得到了进一步的优化,采用了自适应的策略。如果在同一个锁对象上,刚刚有线程使用自旋锁成功获取了锁,并且正处于运行中,就会认为目前采用自旋锁获取的概率比较大,允许自旋运行更多次。当然,如果这个锁经常自旋失败,就有可能会不再使用自旋策略,而是直接使用重量级锁。

4.轻量级锁

在jdk1.6中,为了避免在无竞争的情况使用重量级锁带来的性能损耗。引入了轻量级锁。

在无竞争的情况下(虽然是同步代码块,但是并不是一定会总是处于竞争状态),会使用轻量级锁减少重量级锁所带来的性能消耗。轻量级锁并不能够代替重量级锁,它其实是赌线程不会进入竞争状态,同一时间只有一个线程占用同步资源,从而减少系统内核态与用户态切换,线程阻塞造成线程切换等造成的资源消耗。它并不像重量级锁一样需要向操作系统申请互斥量。下图说明了这一点。

轻量级锁的运行机制如下,在即将执行同步代码块之前,会先检查锁资源对象头中的Mark Word,看看当前锁对象是否被其它线程所占用,如果没有其它线程占用,会在当前线程的栈帧中建立一块区域:Lock Record空间,用于复制并存储Mark Word信息,对数据进行备份。接着使用CAS算法对Displaced MarkWoed变量值编程Lock Record指针,直接指向当前线程的栈帧。

CAS(CompareAndSet)是一种无锁算法,它在修改变量时不会加锁,而是直接进行修改,不过在修改前会查看当前数据值与我们的预期数据值是否一致,如果一致说明没有被其它线程修改,将替换变量值。如果不一致则说明当前数据已经被其它线程修改过,放弃修改变量值。

在cpu中,CAS采用的是cmpxchg指令,能够从底层硬件级别对于cpu效率进行提升。

如果CAS将变量修改成功,Mark Word的数据结构就变成了轻量级结构,编程Lock Record指针,直接指向当前线程的栈帧.

如果CAS执行替换没有成功,则说明可能有线程已经进入了同步代码块中。这时虚拟机会再次检测Mark word,看看是否是指向当前线程的栈帧,如果是,则说明当前线程已经占有了这把锁(可能在之前的操作中已经获取了对同步资源进行了其它修改),就可以大胆的进入同步代码块,如果不是,则同步代码块确实是被其它线程占用了。我们需要将轻量级锁膨胀成为重量级锁(锁的膨胀不可逆)。

上面的过程可以用下面的流程图表示。

解锁的过程同样是采用CAS算法,会将对象头中Mark Word使用CAS算法恢复成栈帧中的Replaced Mark Word,如果恢复成功则成功释放该锁,如果恢复失败,说明其它线程尝试过获取该锁,在释放锁的同时,需要唤醒被挂起的线程。

5.偏向锁

偏向锁比轻量级锁更加极致,干脆就把同步取消掉,不需要进行CAS了。它的发现主要得益于人们发现某个线程可以频繁的获取到锁。

偏向锁其实就是为了单个线程设计的,如果某个锁资源一直是被某个线程获取,而且没有其它线程来获取锁,就可以在Mark Word中记录下这个线程id,该线程就没有必要花时间来进行CAS操作了,可以直接进入到同步代码块。直到发现有其它线程来抢占锁资源了,就会根据当前状态判断是否把偏向锁膨胀成为轻量级锁。

如果需要使用偏向锁,可以使用参数:-XX: UseBiased参数来添加。

值得注意的是,偏向锁的对象头中没有空间存储hash值,如下图:

因此,如果一个对象通过哈希算法计算了一致性哈希值,就不能使用偏向锁,而直接使用轻量级锁。如果一个锁已经是偏向锁,再调用hashcode(),就会将轻量级锁直接退化成为重量级锁。将hash值存放到ObjectMonitor中。

上述过程可以用下图表示。

6.锁消除与锁粗化

如果对于某段代码进行了加锁,但是再运行期间根本不可能出现同步资源竞争的情况,可能会进行锁消除。锁粗化是指某同步代码块频繁的出现同步互斥的情况,需要频繁的判断锁资源,比如在循环中加锁,这样很明显非常消耗性能,虚拟机检测到这种情况就可能出现锁粗化。

例如:

代码语言:javascript复制
public class Demo3 {
    public static void main(String[] args) {
        for (int i = 0; i < 100; i  ) {
            synchronized (Demo3.class){}
        }
    }
}

不如粗化加锁的范围:

代码语言:javascript复制
public class Demo3 {
    public static void main(String[] args) {
        synchronized (Demo3.class){
            for (int i = 0; i < 100; i  ) {
                
            }
        }
    }
}

0 人点赞