Java 并发之底层理论

437 阅读8分钟

在阅读之前先需要了解操作系统的知识

  • 管程
  • 临界资源
  • 信号量
    • 信号量是个特殊的变量,可以简单的认为是可以运行的线程数量,如果信号量最大为1,那么就说明只有一个线程可以运行,那么也就实现了同步。
  • 内核态和用户态,以及什么时候互相切换

大致介绍一下Java 的底层C++写的ObjectMonitor

EntryList 和wait set :只有调用了wait方法的线程在wait set中,其他被阻塞的线程在EntryList中 调用notify方法会将wait set中的一个线程移到EntryList中

  • 当多个线程同时访问一段同步代码时,这些线程会被放到一个EntryList集合中,处于阻塞状态的线程都会被放到该列表当中。接下来,当线程获取到对象的Monitor时,Monitor是依赖于底层操作系统的mutex lock来实现互斥的,线程获取mutex成功,则会持有该mutex,这时其他线程就无法再获取到该mutex。

  • 如果线程调用了wait方法,那么该线程就会释放掉所持有的mutex,并且该线程会进入到waitSet集合(等待集合)中,等待下一次被其他线程调用notify/notifyAll唤醒。如果当前线程顺利执行完毕方法,那么它也会释放掉所持有的mutex。

  • 总结一下∶同步锁在这种实现方式当中,因为Monitor是依赖于底层的操作系统实现,这样就存在用户态与内核态之间的切换,所以会增加性能开销

  • 通过对象互斥锁的概念来保证共享数据操作的完整性。每个对象都对应于一个可称为『互斥锁』的标记,这个标记用于保证在任何时刻,只能有一个线程访问该对象。

  • 那些处于EntryList与WaitSet中的线程均处于阻塞状态,阻塞操作是由操作系统来完成的,在linux下是通过pthread_mutex_lock函数实现的线程被阻塞后便会进入到内核调度状态,这会导致系统在用户态与内核态之间来回切换,严重影响锁的性能。

  • 解决上述问题的办法便是自旋(Spin)。其原理是:当发生对Monior的争用时,若owner能够在很短的时间内释放掉锁,则那些正在争用的线程就可以稍微等待一下(假装在内核态运行,即所谓的自旋),在owner线程释放锁之后,争用线程可能会立刻获取到锁,从而避免了系统阻塞。不过,当owner运行的时间超过了临界值后,争用线程自旋一段时间后依然无法获取到锁,这时争用线程则会停正自旋而进入到阻塞状态。

  • 所以总体的思想是:先自旋,不成功再进行阻塞,尽量降低阻塞的可能性,这对那些执行时间很短的代码块来说有极大的性能提升。显然,自旋在多处理器(多核心)上才有意义。

互斥锁的属性:

  • PTHREAD_MUTEX_TNED_NP:这是缺省值,也就是普通锁。当一个线程加锁以后,其余请求锁的线程将会形成一个等待队列,并且在解锁后按照优先级获取到锁。这种策略可以确保资源分配的公平性。
  • PTHREAD_ MCTEX_RECURSIVE_NP:嵌套锁。充许一个线程对同一个锁成功获取多次,并通过unlock解锁。如果是不同线程请求,则在加锁线程解锁时重新进行竟争。
  • PTHREAD_MUTEX_ERRORCHECK_NP:检错锁。如果一个线程请求同一个锁,则返回BDEADLK,否则与PTHREAD_MUTEX_TNED_NP类型动作相同,这样就保证了当不允许多次加锁时不会出现最简单情况下的死锁。
  • PTHREAD_MUTEX_ADAPTIVE_NP︰适应锁,动作最简单的锁类型,仅仅等待解锁后重新竞争。

锁升级

对象头主要也是由3块内容来构成:

  • Mark Word
  • 指向类的指针
  • 数组长度

其中Mark Word(它记录了对象、锁及垃圾回收相关的信息,在64位的JvVwM中,其长度也是64bit)的位信息 包括了如下组成部分:

  • 无锁标记
  • 偏向锁标记
  • 轻量级锁标记
  • 重量级锁标记
  • gc标记

对于synchronized锁来说

  • 锁的升级主要都是通过Mark Word中的锁标志位与是否是偏向锁标志位来达成的;synchronized关键字所对应的锁是先从偏向锁开始,随着锁竞争的不断升级,逐步演化至轻量级锁,最后则变成了重量级锁。
  • 对于锁的演化来说,它会经历如下阶段:
    • 无锁->偏向锁->轻量级锁->重量级锁

偏向锁

  • JVM自带,JVM假设只有一个线程会抢锁,之后的锁尽量都给它,偏向给第一个获得锁的线程
  • 针对于一个线程来说的,它的主要作用就是优化同一个线程多次获取一个锁的情况;如果一个synchronized方法被一个线程访问,那么这个方法所在的对象就会在其Mark Word中的将偏向锁进行标记,同时还会有一个字段来存储该线程的ID;当这个线程再次访问同一个synchronized方法时,它会检查这个对象的Mark word的偏向锁标记以及是否指向了其线程ID,如果是的话,那么该线程就无需再去进入管程(Monitor)了,而是直接进入到该方法体中.

自旋是轻量级锁的实现,一般在有两个线程时使用

  • 若第一个线程已经获取到了当前对象的锁,这时第二个线程又开始尝试争抢该对象的锁,由于该对象的锁已经被第一个线程获取到,因此它是偏向锁,而第二个线程在争抢时,会发现该对象头中的Mark WKord已经是偏向锁,但里面存储的线程ID并不是自己(是第一个线程),那么它会进行CAS (compare and Swap) ,从而获取到锁,这里面存在两种情况:
    • 获取锁成功︰那么它会直接将Mark Word中的线程ID由第一个线程变成自己(偏向锁标记位保持不变),这样该对象依然会保持偏向锁的状态。
    • 获取锁失败:则表示这时可能会有多个(三个及以上)线程同时在尝试争抢该对象的锁,那么这时偏向锁就会进行升级,升级为轻量级锁

自旋锁:

  • 若自旋失败(依然无法获取到锁),那么锁就会转化为重量级锁,在这种情况下,无法获取到锁的线程都会进入到Monitor(即内核态)
  • 自旋最大的一个特点就是避免了线程从用户态进入到内核态。 重量级锁:
  • 线程最终从用户态进入到了内核态。

不同的锁有不同优缺点,性能不一定是某种锁最好。应该具体问题具体分析,synchronized性能就不一定被lock差

编译器对于锁的优化措施:

  • 在局部变量上同步
public class HelloAndWorld {
    public static void main(String[] args) throws InterruptedException {
        Test test = new Test();
        Runnable run = test::method;//方法引用
        new Thread(run).start();//开启3个线程
        new Thread(run).start();
        new Thread(run).start();
    }
}
class Test{
    public void method(){
        Object object = new Object();
        synchronized (object) {//在局部变量上同步
            try {
                Thread.sleep(7000);//线程睡眠,如果线程是轮流访问的,就会相隔7000毫秒并输出hello world
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("hello world: "+System.currentTimeMillis());//输出系统时间
        }
    }
}

image.png

  • 像上述的在局部变量上同步的类似情况(加锁跟没加一样),为了减少性能损耗Java虚拟机遂引入
    • 锁消除技术
  • JIT编译器(Just In Time编译器)可以在动态编译同步代码时,使用一种叫做逃逸分析的技术,来通过该项技术判别程序中所使用的锁对象是否被一个线程所使用,而没有散布到其他线程当中;如果情况就是这样的话,那么JT编辑器在编译这个同步代码时新不会生成synchYonizedi关键季所徐的锁的申请与释放机器码,从而消除了锁的使用(锁的获取和释放以及调用管程Monitor)流程。
  • 连续的synchronized
class Test{
    Object object = new Object();
    public void method(){
        synchronized (object) {
            System.out.println("hello 王老板");
        }
        synchronized (object) {
            System.out.println("hello 李老板");
        }
        synchronized (object) {
            System.out.println("hello 赵老板");
        }
    }
}
  • 像上述情况用了很多锁实际上像一把锁
  • 为了防止频繁的释放与获取锁以及调用Monitor影响性能,Java虚拟机遂引入了
    • 锁粗化
  • JIT编译器在执行动态编译时,若发现前后相邻的synchronized块使用的是同一个锁对象,那么它就会把这几个synchronized块给合并为一个较大的同步块,这样做的好处在于线程在执行这些代码时,就无需频繁申请与释放锁了,从而达到申请与释放锁一次,就可以执行完全部的同步代码块,从而提升了性能