并发编程之Java进程与线程详解(进阶篇1)

171 阅读10分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第5天,点击查看活动详情

上一篇,我们讲解了Java进程与线程的基础内容,本篇我们来从原理层级上继续深入理解进程和线程。

synchronized关键字

在详细讲解synchronized关键字前,我们先来了解以下的几个问题。

线程安全问题的主要诱因

  • 存在共享数据(也称为临界资源)
  • 存在多个线程共同操作这些共享数据

解决问题的根本方法:同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再对共享数据进行操作。此时,我们可以引入互斥锁。

互斥锁的特性

  • 互斥性:即在同一时间只允许一个线程持有某个对象锁,通过这种特性来实现多线程的协调机制,这样在同一时间只有一个线程对需要同步的代码块进行访问。互斥性也称为操作的原子性
  • 可见性:必须确保在锁被释放之前,对共享变量所做的修改对于随后获得该锁的另一个线程是可见的(即在获得锁时应获得最新共享变量的值),否则另一线程可能是在本地缓存的某个副本上继续操作,从而引起数据不一致的问题。

对于Java来说,关键字synchronized就满足了上述互斥锁的特性。它可以保证在同一时刻只有一个线程可以执行某个方法或代码块,同时synchronized也能够保证一个线程共享数据对其他线程是可见的,也就满足了互斥锁的可见性。synchronized锁的不是代码,锁的都是对象。下面,我们根据获取的锁的分类对synchronized进行讲解。

根据获取的锁的分类可分为:

  • 获取对象锁

    获取对象锁的两种方法:

    1. 同步代码块:synchronized(this)synchronized(类的实例对象)。锁的是实例的对象。
    2. 同步非静态方法:synchronized method锁的是当前对象的实例对象。
  • 获取类锁

    获取类锁的两种方法:

    1. 同步代码块:synchronized(类名.class),锁的是类的对象。
    2. 同步静态方法:synchronized static method,锁的是当前对象的类对象。

对象锁和类锁的总结:

  1. 有线程访问对象的同步代码块时,另外的线程可以访问该对象的非同步代码块。
  2. 类锁和对象锁互不干扰:如果一个线程调用一个实例对象的非静态 synchronized 方法,而另一个线程调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。
  3. 同一个类的不同对象的对象锁互不干扰。

synchronized底层实现原理

要了解synchronized的底层实现,我们首先需要对Java对象头Monitor有一个认识。

Java对象头

在HotSpot虚拟机中,对象在内存中的布局分为三块区域,分别是:对象头实例数据对齐填充。一般而言,Java的锁对象是存储在对象头中的,其主要结构是由Mark WordClass Metadata Address组成。接下来我们将详细解释。

Mark Word:默认存储对象的hashCode,分代年龄,锁类型,锁标志位等信息。考虑到空间效率,Mark Word被设计成非固定的数据结构,便于存储更多有效的数据。它会根据对象本身的状态复用自己的存储空间。

Class Metadata Address:类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的数据。

Monitor

Monitor也称为管层、监视器锁,我们可以把它理解成一种同步机制,通常它被描述为一个对象。Monitor对象存在于每个Java对象的对象头中,在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由ObjectMonitor实现的。每个对象中都内置了一个 ObjectMonitor对象。以下是ObjectMonitor的部分源码:

  // initialize the monitor, exception the semaphore, all other fields
  // are simple integers or pointers
  ObjectMonitor() {
    _header       = NULL;
    _count        = 0;
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL;
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ;
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
  }

从源码中可以看出,ObjectMonitor有两个队列:_EntryList即我们在上一篇讲到的锁池、_WaitSet即等待池。_owner是指向持有ObjectMonitor对象的线程。当多个线程同时访问同一段同步代码时,线程会先进入到EntryList集合中,当线程获取到对象的monitor后,就会进入到object区域,并把monitor中的owner变量设置为当前线程,同时计数器count就会加一。若线程调用wait方法,将释放当前持有的monitorowner就会被恢复成nullcount也会减一,同时该线程及ObjectWaiter实例就会进入到等待池中等待被唤醒。若当前线程执行完毕,它也会释放monitor锁并复位对应变量的值,以便于其他线程进入获取monitor锁。

看完前置知识后,接下来,我们通过一个demo查看经过synchronized修饰的代码块和方法在.class字节码中有什么区别。

编写如下代码:

SynchronizedTest.java

package com.yeliheng.threads;

public class SynchronizedTest {
    //同步代码块
    public void syncBlock() {
        synchronized (this) {
            System.out.println("测试同步代码块");
        }
    }

    //同步方法
    public synchronized void syncMethod() {
        System.out.println("测试同步方法");
    }

}

我们通过JDK自带的javap命令来查看SynchronizedTest类的字节码信息。首先对该类进行编译。

javac SynchronizedTest.java

编译完成后,我们使用javap来获取字节码信息。

javap -verbose SynchronizedTest.class

可以看到如下结果:

mointorenter-exit

从上图我们可以看出,synchronized同步语句块实现使用的是monitorentermonitorexit指令,monitorenter指令指向同步代码块的开始位置,monitorexit则指向结束位置。当执行monitorenter时,线程将尝试获取对象的锁,若锁的计数器count为0时,则代表锁可被获取,获取后count对应加1。流程图如下:

获取锁

并且由于synchronized是可重入锁,线程在之前若已经获得ObjectMonitor的持有权时,可再次在内部调用synchronized,即重入,重入后的count将再次加1。

重入:从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,线程将会处于阻塞状态,但当一个线程再次请求自己持有的对象锁的临界资源时,是允许的。这种情况叫做重入。

同理,拥有该对象锁的线程可以使用monitorexit来释放锁。在执行monitorexit指令后,锁的计数器将被减1,直至计数器为0,表明锁被释放。这时其他线程才可以尝试获得该锁。

接下来我们继续看synchronized修饰同步方法时的情况:

修饰同步方法

此处,我们可以看到,不同于修饰同步代码块,修饰同步方法时并没有monitorentermonitorexit,而是产生了一个ACC_SYNCHRONIZED标识。这个标识用于区分一个方法是否是同步方法。被设置ACC_SYNCHRONIZED的方法也会按照上文讲述的规则进行加锁,它的本质也是对象监视器。

早期的synchronized为什么效率低下?

  • 早期版本中,synchronized属于重量级锁,依赖于操作系统的Mutex Lock实现。
  • 在线程之间的切换需要从用户态转换到核心态,开销较大。

在Java6之后,synchronized关键字做了哪些优化?

虚拟机在Java6之后,对底层的锁进行了大量的优化,引入了:自适应自旋锁、自旋锁、锁消除、锁粗化、偏向锁、轻量级锁。(下文我们会一一介绍) 锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

锁膨胀的方向:无锁->偏向锁->轻量级锁->重量级锁

自旋锁与自适应自旋锁

自旋锁

许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得。自旋锁通过让线程执行忙循环等待锁的释放,不让出CPU。另外地,自旋锁与阻塞并不相同。自旋锁自Java4就存在,只是默认为关闭状态,到Java6后才默认开启。

缺点:若锁被其他线程长时间占用,那循环会带来更多性能上的开销。由于线程执行的时间是不好确定的,所以自适应自旋锁应运而生。

自适应自旋锁

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

锁的优化机制

锁消除

锁消除也是一种JVM自身的优化机制。在JIT编译时,对运行的上下文进行扫描,去除不可能存在竞争的锁。

锁粗化

通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽可能短,但是大某些情况下,一个程序对同一个锁不间断、高频地请求、同步与释放,会消耗掉一定的系统资源。JIT编译时,发现一段代码中频繁的加锁释放锁,会将前后的锁合并为一个锁,避免频繁加锁释放锁。

偏向锁

偏向锁可用于减少同一线程获取锁的代价。在大多数情况下,锁并不存在多线程竞争,而且总是由同一个线程多次获得。为了减少同一个线程获取锁的代价,便引入了偏向锁。偏向锁的核心思想:如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word的结构也变成了偏向锁结构,当该线程再次请求时,无需再做任何同步操作。即获取锁的过程只需要检查Mark Word的锁标记位为偏向锁以及当前线程ID等于Mark Word的ThreadID即可,这样就省去了大量有关锁申请的操作。

注意:由于偏向锁的特性,偏向锁并不适用于锁竞争比较激烈的多线程场合。

轻量级锁

轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁。若存在同一时间访问同一把锁的情况,轻量级锁就会膨胀为重量级锁。

轻量级锁适合线程交替执行同步块的应用场景。

总结

优点缺点适用场景
偏向锁加锁和解锁不需要CAS操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差距如果线程间存在锁竞争,会带来额外的锁撤销的消耗只有一个线程访问同步块或者同步方法的场景
轻量级锁竞争的线程不会阻塞,提高了响应速度若线程长时间抢不到锁,自旋会消耗CPU性能线程交替执行同步块或同步方法的场景
重量级锁线程竞争不使用自旋,不会消耗CPU线程阻塞,响应时间缓慢,在多线程下频繁地获取和释放锁会带来巨大的性能开销追求吞吐量、同步块或者同步方法执行较长的场景

关于JMM内存模型以及volatile关键字,我们拆分到下一篇讲解。