深入拆解 synchronized:从偏向锁到重量级锁的升级之旅与优化秘籍

0 阅读8分钟

在Java并发编程中,synchronized是最基础也最重要的同步工具之一。很多开发者只知道它能保证线程安全,却对其底层实现一知半解。本文将深入JVM底层,拆解synchronized的实现原理,详解从偏向锁、轻量级锁到重量级锁的完整升级流程,并介绍JVM为优化synchronized性能所做的努力。

一、synchronized的基本用法

synchronized可以保证在同一时刻,只有一个线程能执行特定的代码块或方法。它主要有三种使用方式:同步代码块、同步实例方法和同步静态方法。

package com.jam.demo;

import lombok.extern.slf4j.Slf4j;

/**
 * synchronized基本用法示例
 *
 * @author ken
 */
@Slf4j
public class SynchronizedUsageDemo {

    private final Object lock = new Object();
    private static int staticCount = 0;
    private int instanceCount = 0;

    /**
     * 同步代码块:对象锁
     */
    public void syncBlock() {
        synchronized (lock) {
            instanceCount++;
            log.info("同步代码块执行,instanceCount: {}", instanceCount);
        }
    }

    /**
     * 同步实例方法:锁当前对象this
     */
    public synchronized void syncInstanceMethod() {
        instanceCount++;
        log.info("同步实例方法执行,instanceCount: {}", instanceCount);
    }

    /**
     * 同步静态方法:锁当前类的Class对象
     */
    public static synchronized void syncStaticMethod() {
        staticCount++;
        log.info("同步静态方法执行,staticCount: {}", staticCount);
    }

    public static void main(String[] args) {
        SynchronizedUsageDemo demo = new SynchronizedUsageDemo();
        new Thread(demo::syncBlock).start();
        new Thread(demo::syncInstanceMethod).start();
        new Thread(SynchronizedUsageDemo::syncStaticMethod).start();
        new Thread(SynchronizedUsageDemo::syncStaticMethod).start();
    }
}

二、Java对象头与Mark Word

要理解synchronized的底层实现,首先得了解Java对象在内存中的布局。Java对象由三部分组成:对象头、实例数据和对齐填充。其中对象头是实现锁的关键,它包含两部分信息:Mark Word和Klass Pointer。 Mark Word用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志等。在64位JVM中,Mark Word的长度是64bit,其结构会根据锁状态的不同而变化。

锁状态Mark Word内容(64bit)
无锁unused(25bit)identity_hashcode(31bit)unused(1bit)age(4bit)001
偏向锁thread(54bit)epoch(2bit)unused(1bit)age(4bit)101
轻量级锁ptr_to_lock_record(62bit)00
重量级锁ptr_to_monitor(62bit)10
GC标记empty(62bit)11

我们可以通过JOL(Java Object Layout)工具来查看对象头的实际布局。

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.17</version>
</dependency>
package com.jam.demo;

import org.openjdk.jol.info.ClassLayout;

/**
 * 查看对象头布局示例
 *
 * @author ken
 */
public class ObjectHeaderDemo {

    public static void main(String[] args) {
        Object obj = new Object();
        System.out.println(ClassLayout.parseInstance(obj).toPrintable());
    }
}

三、锁升级全流程

JDK 1.6之后,synchronized引入了锁升级机制,以减少获取锁和释放锁带来的性能消耗。锁会从无锁开始,随着竞争的加剧,逐渐升级为偏向锁、轻量级锁,最终升级为重量级锁。

3.1 偏向锁

当只有一个线程访问同步块时,JVM会将锁偏向于该线程,后续该线程再次访问时,无需进行任何同步操作,直接进入同步块。 偏向锁的获取流程:

  1. 线程访问同步块时,检查Mark Word中的线程ID是否为空。
  2. 如果为空,通过CAS操作将Mark Word中的线程ID设置为当前线程ID,此时进入偏向锁状态。
  3. 如果线程ID已存在,检查是否为当前线程ID,如果是,直接执行同步代码;如果不是,说明有其他线程竞争,需要撤销偏向锁。 偏向锁的撤销需要等到全局安全点(此时所有线程都停止执行字节码),然后检查原持有偏向锁的线程是否存活:
  • 如果原线程不存活,将对象头重置为无锁状态,然后重新偏向新线程。
  • 如果原线程存活,升级为轻量级锁。

3.2 轻量级锁

当偏向锁被撤销或有多个线程交替使用锁时,锁会升级为轻量级锁。轻量级锁使用CAS操作来尝试获取锁,避免了重量级锁的线程阻塞和唤醒开销。 轻量级锁的加锁流程:

  1. 线程在自己的栈帧中创建一个名为Lock Record的结构,用于存储Mark Word的副本。
  2. 将对象头中的Mark Word复制到Lock Record中。
  3. 通过CAS操作尝试将对象头的Mark Word替换为指向Lock Record的指针。
  4. 如果CAS成功,当前线程获得轻量级锁,执行同步代码。
  5. 如果CAS失败,检查是否是锁重入:如果是,将Lock Record的displaced_header设为null,作为重入的计数;如果不是,进行自适应自旋。
  6. 如果自旋成功,获得锁;如果自旋失败,升级为重量级锁。 轻量级锁的解锁流程:
  7. 通过CAS操作将Lock Record中的Mark Word复制回对象头。
  8. 如果CAS成功,解锁成功。
  9. 如果CAS失败,说明锁已经升级为重量级锁,需要释放锁并唤醒等待的线程。

3.3 重量级锁

当锁竞争激烈,轻量级锁的CAS操作和自旋都无法获取锁时,锁会升级为重量级锁。重量级锁基于ObjectMonitor实现,此时线程会被阻塞,等待锁释放后被唤醒。 ObjectMonitor是JVM内部的一个对象,包含以下关键字段:

  • _owner:指向持有锁的线程。
  • _EntryList:存储等待获取锁的线程。
  • _WaitSet:存储调用wait()方法后等待的线程。 重量级锁的加锁流程:
  1. 线程进入_EntryList,状态变为BLOCKED。
  2. 当_owner线程释放锁时,会从_EntryList中唤醒一个线程。
  3. 被唤醒的线程尝试获取锁,如果成功,成为新的_owner。 重量级锁的解锁流程:
  4. _owner线程释放锁,将_owner设为null。
  5. 从_EntryList中唤醒一个线程,让它尝试获取锁。

四、锁优化机制

JVM为了提高synchronized的性能,引入了多种锁优化机制,包括自适应自旋、锁消除、锁粗化和逃逸分析。

4.1 自适应自旋

轻量级锁加锁失败时,线程会进行自旋等待,而不是直接阻塞。自适应自旋意味着自旋的时间不是固定的,而是根据前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果前一次自旋成功获取了锁,JVM会认为这次自旋也很可能成功,从而延长自旋时间;如果前一次自旋失败,JVM会缩短自旋时间,甚至直接跳过自旋。

4.2 锁消除

锁消除是指JIT编译器在运行时,通过逃逸分析发现某个对象不会被其他线程访问,那么该对象的锁就可以被消除。因为不会有线程竞争,所以加锁和解锁操作是多余的。

package com.jam.demo;

import lombok.extern.slf4j.Slf4j;

/**
 * 锁消除示例
 *
 * @author ken
 */
@Slf4j
public class LockEliminationDemo {

    /**
     * 方法中的StringBuffer是局部变量,不会逃逸,JIT会消除锁
     */
    public String concat(String a, String b, String c) {
        StringBuffer sb = new StringBuffer();
        sb.append(a);
        sb.append(b);
        sb.append(c);
        return sb.toString();
    }

    public static void main(String[] args) {
        LockEliminationDemo demo = new LockEliminationDemo();
        for (int i = 0; i < 100000; i++) {
            demo.concat("a""b""c");
        }
        log.info("执行完成");
    }
}

4.3 锁粗化

锁粗化是指将连续的加锁和解锁操作合并成一个更大的锁范围,减少加锁和解锁的次数。如果一个线程连续对同一个对象进行多次加锁和解锁,JIT会将这些操作合并,只进行一次加锁和解锁。

package com.jam.demo;

import lombok.extern.slf4j.Slf4j;

/**
 * 锁粗化示例
 *
 * @author ken
 */
@Slf4j
public class LockCoarseningDemo {

    private final Object lock = new Object();
    private int count = 0;

    /**
     * 连续的加锁解锁,JIT会粗化锁范围
     */
    public void increment() {
        synchronized (lock) {
            count++;
        }
        synchronized (lock) {
            count++;
        }
        synchronized (lock) {
            count++;
        }
    }

    public static void main(String[] args) {
        LockCoarseningDemo demo = new LockCoarseningDemo();
        for (int i = 0; i < 100000; i++) {
            demo.increment();
        }
        log.info("count: {}", demo.count);
    }
}

4.4 逃逸分析

逃逸分析是JVM的一种分析技术,用于判断对象的作用域是否会逃逸出方法或线程。如果对象不会逃逸,JVM可以进行以下优化:

  • 栈上分配:将对象分配在栈上,而不是堆上,减少GC压力。
  • 标量替换:将对象分解成多个基本类型,直接分配在栈上。
  • 锁消除:如前面所述,消除不会逃逸对象的锁。

五、实战:死锁排查

在使用synchronized时,如果不注意加锁顺序,可能会导致死锁。死锁是指两个或多个线程互相等待对方释放锁,导致所有线程都无法继续执行。

package com.jam.demo;

import lombok.extern.slf4j.Slf4j;

/**
 * 死锁示例
 *
 * @author ken
 */
@Slf4j
public class DeadlockDemo {

    private static final Object LOCK_A = new Object();
    private static final Object LOCK_B = new Object();

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (LOCK_A) {
                log.info("线程1获取到LOCK_A");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    log.error("线程中断", e);
                }
                synchronized (LOCK_B) {
                    log.info("线程1获取到LOCK_B");
                }
            }
        }, "线程1").start();

        new Thread(() -> {
            synchronized (LOCK_B) {
                log.info("线程2获取到LOCK_B");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    log.error("线程中断", e);
                }
                synchronized (LOCK_A) {
                    log.info("线程2获取到LOCK_A");
                }
            }
        }, "线程2").start();
    }
}

运行程序后,我们可以通过jstack工具排查死锁:

  1. 用jps命令找到进程ID。
  2. 用jstack <进程ID>查看线程堆栈,会看到明确的死锁提示。

synchronized的底层实现涉及对象头、Mark Word、锁升级和各种优化机制。理解这些原理,不仅能帮助我们写出更高效的并发代码,还能在遇到并发问题时快速定位和解决。