synchronized 存贮 偏向锁 轻量级锁 重量锁 + 一丢丢 hotspot 源码

x33g5p2x  于2021-09-18 转载在 其他  
字(6.9k)|赞(0)|评价(0)|浏览(194)

杂谈

距离上次发博文,已经过去快一个月了,为啥那么长时间没发呢?其中原因一:想写一篇spring源码的博文,但是!写了快一个星期,发现有的是写得好的大神,并且,给我学弟看完他说看不懂!!那就算了…原因二:项目上线,加班加吐了…

这篇来讲讲 锁****synchronized

说实话,工作那么久,大部分时间是拧螺丝,用到多线程的次数不能说没有,但是也是十分的少,平常网上看到的 多线程深入理解 也是教你怎么用,其实并没有深入底层去探究。

在无意间观看了某泡学院的公开课后,才意识到,还有挺多东西并没有接触到,花费了九牛二虎之力,找客服小姐姐拿到了公开课的资料后,在委婉的拒绝报班的需求,O(∩_∩)O哈哈~

结合自己的理解,写一下总结吧,这篇博文只能算 简单总结,后续还会不断完善的。

正文

前期准备

需要下载 hotspot 的源码(小伙子们,有点心理准备 C++ 这才叫看源码(看不懂啊…))

连接一:http://hg.openjdk.java.net/jdk8/jdk8/hotspot/

连接二:http://hg.openjdk.java.net/jdk8/jdk8/hotspot/archive/tip.zip

为什么会有锁?

首先我们需要知道为什么会有线程?

  • 在多核 CPU 中,利用多线程可以实现真正意义上的并行执行。
  • 在一个应用进程中,会存在多个同时执行的任务,如果其中一个任务被阻塞,将会引起不依赖该任务的任务也被阻塞。通过对不同任务创建不同的线程去处理,可以提升程序处理的实时性。
  • 线程可以认为是轻量级的进程,所以线程的创建、销毁比进程更快。

在 java 中有多种方式实现多线程:继承 Thread 类 / 实现 Runnable 接口 / 使用 ExecutorService、Callable、Future 实现带返回结果的多线程。


线程会带来什么问题

线程的合理使用能够提升程序的处理性能,主要有两个方面:

  • 能够利用多核 cpu 以及 超线程技术 来实现线程的并行执行。
  • 线程的异步化执行相比于同步执行来说,异步执行能够很好的优化程序的处理性能提升并发吞吐量。

使用线程,最核心的目的就是提高效率!

但是当程序处于多线程状态,并且访问同一变量的时候,会发生数据不一致的问题。例如:启动多个线程对变量进行增加

public class TestDemo {

    public static int number = 0;

    public static void plus(){

        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        number++;
    }

    @Test
    public void testDemo() throws InterruptedException {

      for (int i  = 0;i<1000;i++){
          new Thread(TestDemo::plus).start();
      }
        Thread.sleep(88888);
        System.out.println("运行结果"+number);
    }

}

预期结果是1000,但是实际结果却是:

在这里插入图片描述

这就是线程不安全的例子,那么什么是线程安全呢?

我认为线程安全的本质就是一个数据的状态是否能被多个线程访问并且修改。如果能,那么在多线程的使用环境下,不对它进行额外的操作,却能保证数据的准确性,它就是线程安全的,否则就不是线程安全的。

对于以上的例子,只需要加 synchronized 就成

public class TestDemo {

    public static int number = 0;

    public static void plus(){
        synchronized(TestDemo.class){
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            number++;
        }
    }

    @Test
    public void testDemo() throws InterruptedException {

      for (int i  = 0;i<1000;i++){
          new Thread(TestDemo::plus).start();
      }
        Thread.sleep(88888);
        System.out.println("运行结果"+number);
    }

}

在这里插入图片描述


锁是怎么存储的?

synchronized(TestDemo.class)

使用锁的目的就是实现线程安全,使得多线程互斥,达到线程安全的效果,那么他需要以下因数:

  • 需要一种方式表示锁在各个周期中处于什么状态。
  • 需要使得这个状态对多个线程共享。

其实我们不难发现,synchronized 是基于括号中的对象的生命周期来控制锁的力度,那么存贮就应该是和这个对象有关。

Jvm 源码的实现(参考了书籍以及网络上他人的解释)

在 java 代码中,我们实例化创建一个对象的时候 ,(hotspot 虚拟机)JVM 层面实际上会创建一个 instanceOopDesc 对象。

instanceOopDesc 的定义在 Hotspot 源 码 中 的 instanceOop.hpp 文件中,另外,arrayOopDesc 的定义对应 arrayOop.hpp

#ifndef SHARE_VM_OOPS_INSTANCEOOP_HPP
#define SHARE_VM_OOPS_INSTANCEOOP_HPP

#include "oops/oop.hpp"

// An instanceOop is an instance of a Java Class
// Evaluating "new HashTable()" will create an instanceOop.

class instanceOopDesc : public oopDesc {
 public:
  // aligned header size.
  static int header_size() { return sizeof(instanceOopDesc)/HeapWordSize; }

  // If compressed, the offset of the fields of the instance may not be aligned.
  static int base_offset_in_bytes() {
    // offset computation code breaks if UseCompressedClassPointers
    // only is true
    return (UseCompressedOops && UseCompressedClassPointers) ?
             klass_gap_offset_in_bytes() :
             sizeof(instanceOopDesc);
  }

  static bool contains_field_offset(int offset, int nonstatic_field_size) {
    int base_in_bytes = base_offset_in_bytes();
    return (offset >= base_in_bytes &&
            (offset-base_in_bytes) < nonstatic_field_size * heapOopSize);
  }
};

#endif // SHARE_VM_OOPS_INSTANCEOOP_HPP

instanceOopDesc 代码中可以看到 instanceOopDesc 继承自 oopDescoopDesc 的定义载 Hotspot 源码中的 oop.hpp 文件中。

在普通实例对象中,oopDesc 的定义包含两个成员,分别是 _mark_metadata

  • _mark:又名 Mark World ,它记录了对象和锁有关的信息。
  • _metadata :表示类元信息,类元信息存储的是对象指向它的类元数据(Klass)的首地址,其中 Klass 表示普通指针,_compressed_klass 表示压缩类指针。

MarkWord

在 Hotspot 中,markOop 的定义在 markOop.hpp 文件中,代码如下

class markOopDesc: public oopDesc {
 private:
  // Conversion
  uintptr_t value() const { return (uintptr_t) this; }

 public:
  // Constants
  enum { age_bits                 = 4,
         lock_bits                = 2,
         biased_lock_bits         = 1,
         max_hash_bits            = BitsPerWord - age_bits - lock_bits - biased_lock_bits,
         hash_bits                = max_hash_bits > 31 ? 31 : max_hash_bits,
         cms_bits                 = LP64_ONLY(1) NOT_LP64(0),
         epoch_bits               = 2
  };

  // The biased locking code currently requires that the age bits be
  // contiguous to the lock bits.
  enum { lock_shift               = 0,
         biased_lock_shift        = lock_bits,
         age_shift                = lock_bits + biased_lock_bits,
         cms_shift                = age_shift + age_bits,
         hash_shift               = cms_shift + cms_bits,
         epoch_shift              = hash_shift
  };
......

Mark word 记录了对象和锁有关的信息,当某个对象被 synchronized 关键字当成同步锁时,那么围绕这个锁的一系列操作都和 Mark word 有关系。Mark Word 在 32 位虚拟机的长度是 32bit、在 64 位虚拟机的长度是 64bit。Mark Word 里面存储的数据会随着锁标志位的变化而变化,Mark Word 可能变化为存储以下 5 中情况

在这里插入图片描述


为什么任何对象都可以实现锁 ?

  • Java 中的每个对象都派生自 Object 类,而每个Java Object 在 JVM 内部都有一个 native 的 C++对象oop/oopDesc 进行对应。
  • 线程在获取锁的时候,实际上就是获得一个监视器对象(monitor) ,monitor 可以认为是一个同步对象,所有的Java 对象是天生携带 monitor。

在 hotspot 源码的 markOop.hpp 文件中,可以看到下面这段代码

ObjectMonitor* monitor() const {
    assert(has_monitor(), "check");
    // Use xor instead of &~ to provide one extra tag-bit check.
    return (ObjectMonitor*) (value() ^ monitor_value);
  }

多个线程访问同步代码块时,相当于去争抢对象监视器修改对象中的锁标识,上面的代码中ObjectMonitor这个对象和线程争抢锁的逻辑有密切的关系。

synchronized 锁的升级

对于多线程的使用,有一个绕不过去的问题,就是性能!使用了 synchronized 之后,会导致性能问题,而不使用会使得有线程安全问题。

所以 hotspot 虚拟机的作者经过调查发现,大部分情况下,加锁的代码不仅仅不存在多线程竞争,而且总是由同一个线程多次获得。所以基于这样一个概率,是的 synchronizedJDK1.6 之后做了一些优化。

在JDK1.6之前,锁都是重量锁,会导致线程的阻塞,而优化之后,在 synchronized 就有了四种状态 :

  • 无锁。
  • 偏向锁。
  • 轻量级锁。
  • 重量级锁。

无锁就不讲了,讲讲其它的…

偏向锁

前面有提到,hotspot 虚拟机的作者经过调查发现,大部分情况下,加锁的代码不仅仅不存在多线程竞争,而且总是由同一个线程多次获得。所以在优化的时候引入了偏向锁的概念:

当有一个线程访问了同步锁的代码块时,会在对象头中存贮当前线程的id,后续进入这个线程和退出这段加了同步锁的代码块时,不用再次加锁和释放锁,而是直接比较对象头里面是否存储了相同的线程id,如果相同,就不需要再加锁而是直接执行代码块的代码。

偏向锁的获取
  • 首先获取锁 对象的 Markword,判断是否处于可偏向状态。(biased_lock=1、且 ThreadId 为空)。
  • 如果是可偏向状态,则通过 CAS (Compare and Swap / 比较再交换) 操作,把当前线程的 ID写入到 MarkWord。
    CAS成功,那么 Markword 就会变成表示已经获得了锁对象的偏向锁,接着执行同步代码块。
    CAS失败,说明有其他线程已经获得了偏向锁,这种情况说明当前锁存在竞争,需要撤销已获得偏向锁的线程,并且把它持有的锁升级为轻量级锁(这个操作需要等到全局安全点,也就是没有线程在执行字节码)才能执行。
  • 如果是已偏向状态,需要检查 markword 中存储的ThreadID 是否等于当前线程的 ThreadID。
    相等,则直接执行同步代码块,不需要再次获得锁。
    不相等,说明当前锁偏向于其它线程,需要撤销锁并升级到轻量级锁。
偏向锁的撤销

偏向锁的撤销并不是把对象恢复到无锁可偏向状态(因为偏向锁并不存在锁释放的概念),而是在获取偏向锁的过程中,发现 cas 失败也就是存在线程竞争时,直接把被偏向的锁对象升级到被加了轻量级锁的状态。

对原持有偏向锁的线程进行撤销时,原获得偏向锁的线程有两种情况:

  1. 原获得偏向锁的线程如果已经退出了临界区,也就是同步代码块执行完了,那么这个时候会把对象头设置成无锁状态并且争抢锁的线程可以基于 CAS 重新偏向但前线程。
  2. 如果原获得偏向锁的线程的同步代码块还没执行完,处于临界区之内,这个时候会把原获得偏向锁的线程升级为轻量级锁后继续执行同步代码块。

但是,在我们的项目开发中,往往若只有一个线程访问,是不会加锁的,绝大部分加锁是基于两个以上的线程竞争,这种情况下,如果开启了偏向锁,反而会提升获取锁的资源消耗。所以可以通过 jvm 参数 UseBiasedLocking 来设置开启或关闭偏向锁。

流程图如下:

在这里插入图片描述

轻量级锁

锁升级为轻量级锁之后,对象的 Markword 也会进行相应的变化。

升级为轻量级锁的过程:

  1. 线程在自己的栈桢中创建锁记录 LockRecord。
  2. 将锁对象的对象头中的MarkWord复制到线程的刚刚创建的锁记录中。
  3. 将锁记录中的 Owner 指针指向锁对象。
  4. 将锁对象的对象头的 MarkWord替换为指向锁记录的指针。

在这里插入图片描述

在这里插入图片描述

自旋锁

轻量级锁在加锁过程中,用到了自旋锁所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的。
注意,锁在原地循环的时候,是会消耗 cpu 的,就相当于在执行一个啥也没有的 for 循环。

所以,轻量级锁适用于那些同步代码块执行的很快的场景,这样,线程原地等待很短的时间就能够获得锁了。自旋锁的使用,其实也是有一定的概率背景,在大部分同步代码块执行的时间都是很短的。所以通过看似无异议的循环反而能提升锁的性能。

但是自旋必须要有一定的条件控制,否则如果一个线程执行同步代码块的时间很长,那么这个线程不断的循环反而会消耗 CPU 资源。默认情况下自旋的次数是 10 次,可以通过 preBlockSpin 来修改。

在 JDK1.6 之后,引入了自适应自旋锁,自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自
旋的时间以及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

可以用一句话总结:当处于轻量锁的时候,会不断的尝试获取是否能拿到锁,要是拿不到,到达一定次数的时候会阻塞,变成重量锁。

轻量级锁的解锁

轻量级锁的锁释放逻辑其实就是获得锁的逆向逻辑,通过CAS 操作把线程栈帧中的 LockRecord 替换回到锁对象的MarkWord 中,如果成功表示没有竞争。如果失败,表示当前锁存在竞争,那么轻量级锁就会膨胀成为重量级锁。

在这里插入图片描述

重量级锁

当轻量级锁膨胀到重量级锁之后,意味着线程只能被挂起阻塞来等待被唤醒了。

每一个 JAVA 对象都会与一个监视器 monitor 关联,我们可以把它理解成为一把锁,当一个线程想要执行一段被synchronized 修饰的同步方法或者代码块时,该线程得先获取到 synchronized 修饰的对象对应的 monitor

monitorenter 表示去获得一个对象监视器。monitorexit 表示释放 monitor 监视器的所有权,使得其他被阻塞的线程可以尝试去获得这个监视器monitor 依赖操作系统的 MutexLock(互斥锁)来实现的, 线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能。

在这里插入图片描述

END

以上就是对于锁的一点总结,参考了一些书籍和其它博主的博文。

参考书籍如下:

  • 《并发编程的艺术》
  • 《并发编程实战》

相关文章