JVM——Synchronized:同步锁的原理及应用

发布于:2025-06-24 ⋅ 阅读:(17) ⋅ 点赞:(0)

引入

在多线程编程的世界里,共享资源的访问控制就像一场精心设计的交通管制,而Synchronized作为Java并发编程的基础同步机制,扮演着"交通警察"的关键角色。

并发编程的核心矛盾

当多个线程同时访问共享资源时,"线程安全"问题便应运而生。想象一个银行账户的场景:若两个线程同时执行扣款操作,可能导致账户余额出现负数或不一致的情况。这种情况下,我们需要一种机制来确保在同一时刻只有一个线程能操作共享资源,这就是同步锁的核心使命。

Synchronized的历史地位

作为Java语言内置的同步机制,Synchronized从JDK1.0时代便已存在。早期版本中,它因"重量级锁"的标签被认为性能不佳,但随着JDK6之后的一系列优化(如偏向锁、轻量级锁的引入),其性能表现已大幅提升,在很多场景下甚至优于ReentrantLock

同步锁的三大核心特性

  • 互斥性:确保同一时刻只有一个线程获取锁并执行同步代码

  • 可见性:保证释放锁时对共享变量的修改能立即被其他线程看见

  • 有序性:禁止指令重排序,确保同步代码块内的操作按顺序执行

这些特性通过JVM底层的监视器锁(Monitor)机制实现,是理解Synchronized的关键切入点。

锁的状态与类型:从无锁到重量级锁的演进

JVM视角下的锁状态体系

从JVM实现层面看,锁的状态可分为4种,每种状态对应不同的竞争程度和性能特征:

状态值 状态名称 竞争程度 典型场景 Mark Word结构(64位JVM)
0 无锁状态 无竞争 单线程访问 对象哈希码(25bit) + GC分代年龄(4bit) + 锁标志位(01) + 偏向锁标志(0)
1 偏向锁状态 轻微竞争 单线程重复访问 线程ID(54bit) + GC分代年龄(4bit) + 锁标志位(01) + 偏向锁标志(1)
2 轻量级锁状态 中度竞争 多线程自旋等待 指向栈中锁记录的指针(62bit) + 锁标志位(00)
3 重量级锁状态 高度竞争 多线程阻塞等待 指向监视器对象的指针(62bit) + 锁标志位(10)

锁状态转换图

无锁状态 ↔ 偏向锁状态 ↔ 轻量级锁状态 ↔ 重量级锁状态
        ↑                                 ↓
        └────────────── 锁膨胀 ────────────┘

这种状态转换是单向不可逆的,只能从低竞争状态向高竞争状态升级(锁膨胀),而不能降级,这是JVM为了优化性能做出的设计选择。

偏向锁:单线程优化的利器

偏向锁的核心思想

偏向锁是JVM对"同一线程多次获取同一锁"场景的优化,它通过在对象头中记录线程ID的方式,避免重复获取锁的开销。当一个线程首次获取对象锁时,JVM会将偏向锁标志位设为1,并将线程ID写入Mark Word,后续该线程再次访问时无需进行CAS操作,直接判断Mark Word中的线程ID是否与当前线程一致。

偏向锁的激活与撤销

  • 激活条件:JVM参数-XX:+UseBiasedLocking(JDK6后默认启用)

  • 撤销场景

    • 当其他线程尝试获取偏向锁时,会触发偏向锁撤销

    • 调用wait()/notify()等方法时,偏向锁会升级为轻量级锁

    • 偏向锁可以通过BiasedLockingStartupDelay参数控制延迟激活时间

典型应用场景

偏向锁最适合"单线程反复访问同步资源"的场景,例如:

public class BiasedLockDemo {
    private Object lock = new Object();
    
    public void doWork() {
        synchronized (lock) {
            // 单线程频繁执行的业务逻辑
        }
    }
}

在这种场景下,偏向锁能消除几乎所有的锁获取开销。

轻量级锁:自旋等待的艺术

轻量级锁的实现原理

当偏向锁被撤销或遇到轻度竞争时,锁会升级为轻量级锁。其核心原理是通过CAS操作在栈帧中创建"锁记录"(Lock Record),并将对象头的Mark Word替换为指向锁记录的指针:

  1. 线程在栈中创建Lock Record,复制对象头Mark Word到Lock Record(Displaced Mark Word)

  2. 尝试用CAS将对象头Mark Word替换为指向Lock Record的指针

  3. CAS成功则获取锁,失败则进入自旋等待

  4. 自旋一定次数后仍未获取锁,则升级为重量级锁

自旋优化的权衡

自旋等待(Spin Waiting)是指线程不放弃CPU,而是循环检查锁是否可用。这种方式避免了线程阻塞的开销,但会消耗CPU资源。JVM通过-XX:PreBlockSpin参数控制自旋次数,默认值为10次。在多核CPU环境下,自旋优化能显著提升轻度竞争场景的性能。

轻量级锁与偏向锁的对比

特性 偏向锁 轻量级锁
竞争程度 无竞争 轻度竞争
加锁方式 CAS记录线程ID CAS修改对象头指针
解锁开销 几乎无 需CAS还原Mark Word
典型场景 单线程反复访问 多线程交替访问

重量级锁:操作系统级别的同步

重量级锁的底层实现

当轻量级锁自旋超过阈值或竞争更加激烈时,锁会膨胀为重量级锁。此时JVM会调用操作系统的互斥量(Mutex)来实现线程阻塞,具体过程包括:

  1. 创建与对象关联的监视器(Monitor)对象

  2. 线程进入监视器的等待队列,状态变为BLOCKED

  3. 释放CPU资源,等待操作系统调度唤醒

  4. 唤醒后重新尝试获取锁

重量级锁的性能开销

重量级锁的性能开销主要来自:

  • 线程状态切换(用户态→内核态→用户态)

  • 操作系统调度器的上下文切换

  • 等待队列的管理开销

在JDK6之前,Synchronized默认使用重量级锁,这也是其"性能不佳"印象的来源。但经过锁升级优化后,重量级锁的使用场景已大幅减少。

锁膨胀的触发条件

锁状态从低到高升级的关键触发条件包括:

  • 偏向锁遇到其他线程竞争

  • 轻量级锁自旋次数超过阈值(默认10次)

  • 调用Object.wait()等会导致线程阻塞的方法

  • 锁竞争持续时间超过自旋优化的收益临界点

Synchronized与Java内存模型(JMM)的深层联系

JMM的核心架构

Java内存模型定义了线程和主内存之间的抽象关系:

  • 主内存:所有线程共享的内存区域,存储共享变量

  • 工作内存:每个线程私有的内存区域,存储共享变量的副本

这种架构导致了一个核心问题:线程间如何保证共享变量的可见性?Synchronized通过以下机制解决这一问题:

Synchronized的内存语义

当线程执行synchronized同步块时,会遵循以下内存规则:

  1. 进入同步块

    • 从主内存读取共享变量的最新值到工作内存

    • 清空工作内存中与同步块相关的变量副本

    • 保证同步块内操作的有序性(禁止指令重排序)

  2. 退出同步块

    • 将工作内存中的变量修改刷新到主内存

    • 确保所有对共享变量的修改对其他线程可见

    • 建立happens-before关系,保证后续线程能看到最新数据

这种机制通过JVM在编译时生成的monitorentermonitorexit指令实现,确保了同步操作的可见性和有序性。

happens-before原则与Synchronized

JMM中的happens-before原则定义了操作之间的偏序关系,其中与Synchronized相关的规则包括:

  • 监视器锁规则:对一个锁的解锁操作happens-before于后续对该锁的加锁操作

  • 程序顺序规则:同步块内的操作按程序顺序执行

  • 传递性:若A happens-before B且B happens-before C,则A happens-before C

这些规则共同保证了Synchronized同步块内操作的正确性,例如:

private int x = 0;
private Object lock = new Object();
​
public void update() {
    synchronized (lock) {
        x = 1; // 操作1
        x = 2; // 操作2
    } // 解锁操作,happens-before后续加锁操作
}
​
public void read() {
    synchronized (lock) { // 加锁操作,happens-after解锁操作
        assert x == 2; // 一定成立
    }
}

由于解锁操作happens-before加锁操作,读操作必然能看到写操作的最新结果。

其他同步解决方案:与Synchronized的对比与互补

ReentrantLock:灵活的显式锁

ReentrantLock的核心特性

ReentrantLock(可重入锁)是JUC包中提供的同步工具,与Synchronized相比具有以下优势:

  • 显式锁控制:通过lock()unlock()方法显式获取和释放锁

  • 可中断获取锁:支持lockInterruptibly()方法,可响应中断

  • 公平锁机制:支持公平锁模式,保证线程获取锁的顺序

  • 条件变量:通过newCondition()方法创建条件变量,实现更灵活的等待/通知机制

公平锁与非公平锁的实现差异

ReentrantLock支持两种锁模式:

  • 非公平锁(默认):新线程可能在等待队列头部线程之前获取锁,性能更高

  • 公平锁:严格按照线程等待顺序获取锁,避免饥饿

// 创建公平锁
ReentrantLock fairLock = new ReentrantLock(true);
​
// 使用示例
fairLock.lock();
try {
    // 同步代码块
} finally {
    fairLock.unlock();
}

公平锁通过AQS(AbstractQueuedSynchronizer)的等待队列实现,而非公平锁在获取锁时会先尝试直接获取,可能跳过等待队列中的线程。

ReentrantLock与Synchronized的对比

特性 Synchronized ReentrantLock
锁获取方式 隐式(自动加锁/解锁) 显式(手动调用方法)
可重入性 支持 支持
公平性 非公平 可选择公平/非公平
锁中断 不支持 支持
条件变量 不支持 支持
性能(无竞争) 优(偏向锁优化) 略逊
性能(高竞争) 略逊 优(可中断、公平锁)

ReadWriteLock:读写分离的同步策略

读写锁的核心思想

ReadWriteLock(读写锁)将锁分为读锁和写锁,允许多个线程同时获取读锁,但同一时刻只能有一个线程获取写锁。这种设计特别适合"读多写少"的场景,例如缓存系统:

public class Cache {
    private final ReadWriteLock lock = new ReentrantReadWriteLock();
    private Map<String, Object> data = new HashMap<>();
    
    // 读操作获取读锁
    public Object get(String key) {
        lock.readLock().lock();
        try {
            return data.get(key);
        } finally {
            lock.readLock().unlock();
        }
    }
    
    // 写操作获取写锁
    public void put(String key, Object value) {
        lock.writeLock().lock();
        try {
            data.put(key, value);
        } finally {
            lock.writeLock().unlock();
        }
    }
}

读写锁的状态管理

ReentrantReadWriteLock通过一个整数(32位)来管理两种锁状态:

  • 高16位:记录读锁的获取次数(可被多个线程共享)

  • 低16位:记录写锁的获取次数(仅能被一个线程持有)

这种设计使得读写锁能在一个变量中维护两种锁状态,提高了空间效率。

读写锁的适用场景

读写锁适合以下场景:

  • 读取操作频率远高于写入操作

  • 写入操作耗时较短

  • 需要保证读取操作的一致性

例如:

  • 配置文件读取(很少修改,频繁读取)

  • 缓存系统(读多写少)

  • 数据库查询缓存(查询频繁,更新较少)

但需注意,读写锁在写操作频繁的场景下性能可能不如普通互斥锁,因为读锁的释放可能导致写锁饥饿。

Synchronized在JDK源码中的典型应用

容器类中的同步实现

StringBuffer的同步实现

StringBuffer是JDK中典型的线程安全容器,其所有关键方法都使用Synchronized修饰:

public final class StringBuffer extends AbstractStringBuilder implements java.io.Serializable, CharSequence {
    // 构造函数
    public StringBuffer() {
        super(16);
    }
    
    // 同步追加方法
    public synchronized StringBuffer append(String str) {
        toStringCache = null;
        super.append(str);
        return this;
    }
    
    // 同步插入方法
    public synchronized StringBuffer insert(int offset, char str[]) {
        toStringCache = null;
        super.insert(offset, str);
        return this;
    }
    // 其他同步方法...
}

这种实现方式保证了StringBuffer在多线程环境下的安全性,但也意味着所有操作都需要获取锁,在高并发场景下可能成为性能瓶颈。

Vector的同步机制

Vector与ArrayList功能相似,但所有操作都是同步的:

public class Vector<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
    // 同步添加元素
    public synchronized boolean add(E e) {
        modCount++;
        ensureCapacityHelper(elementCount + 1);
        elementData[elementCount++] = e;
        return true;
    }
    
    // 同步获取元素
    public synchronized E get(int index) {
        if (index >= elementCount)
            throw new ArrayIndexOutOfBoundsException(index);
        return elementData(index);
    }
    // 其他同步方法...
}

与StringBuffer类似,Vector的同步实现保证了线程安全,但在并发环境下性能不如非同步容器。JDK推荐在非必要时使用ArrayList,仅在需要线程安全时通过Collections.synchronizedList(new ArrayList<>())包装。

基础类库中的同步应用

Hashtable的同步实现

Hashtable是Java早期的线程安全哈希表,其实现方式与Vector类似:

public class Hashtable<K,V> extends Dictionary<K,V> implements Map<K,V>, Cloneable, java.io.Serializable {
    // 同步put方法
    public synchronized V put(K key, V value) {
        // 检查key是否为null
        if (key == null) {
            throw new NullPointerException();
        }
        
        V oldValue = get(key);
        putVal(key, value, false);
        return oldValue;
    }
    
    // 同步get方法
    public synchronized V get(Object key) {
        if (key == null) {
            throw new NullPointerException();
        }
        
        Entry<?,?> e = getEntry(key);
        return (e == null) ? null : (V)e.value;
    }
    // 其他同步方法...
}

由于Hashtable的同步粒度较大(整个哈希表),在高并发场景下性能较差,因此JDK后来提供了ConcurrentHashMap作为替代方案,其采用分段锁机制大幅提升了并发性能。

自定义同步工具的基础

Synchronized也是JDK中许多自定义同步工具的实现基础,例如:

public class Semaphore {
    // 内部通过AQS实现,但AQS的底层操作依赖于CAS和Monitor
    public Semaphore(int permits) {
        sync = new NonfairSync(permits);
    }
    
    // 其他方法...
}

虽然Semaphore的上层接口不直接使用Synchronized,但底层AQS的实现仍然依赖于JVM的监视器锁机制,体现了Synchronized在JDK中的基础地位。

Synchronized的最佳实践与性能优化

精细化同步范围

最小化同步代码块

// 反例:同步范围过大
public void badPractice() {
    synchronized (this) {
        // 非共享资源操作,无需同步
        loadConfig();
        // 共享资源操作,需要同步
        updateSharedData();
        // 非共享资源操作,无需同步
        logOperation();
    }
}

// 正例:缩小同步范围
public void goodPractice() {
    // 非共享资源操作
    loadConfig();
    
    // 仅同步必要的代码块
    synchronized (this) {
        updateSharedData();
    }
    
    // 非共享资源操作
    logOperation();
}

通过缩小同步范围,可以减少线程竞争,提高并发性。基本原则是:只对真正访问共享资源的代码加锁

同步对象的选择

优先使用私有锁对象

private final Object lock = new Object();

public void operation() {
    synchronized (lock) {
        // 同步代码
    }
}

私有锁对象避免了外部代码直接访问锁,减少了锁竞争的意外风险。

避免使用this作为锁对象

public void badLock() {
    synchronized (this) { // 危险!外部可能获取this锁导致死锁
        // 同步代码
    }
}

除非类本身设计为线程安全的同步组件,否则应避免使用this作为锁对象。

利用锁的可重入性

可重入性的实际应用

public class ReentrantDemo {
    public synchronized void method1() {
        System.out.println("进入method1");
        method2(); // 调用同步方法method2
        System.out.println("退出method1");
    }
    
    public synchronized void method2() {
        System.out.println("进入method2");
        // 其他操作
        System.out.println("退出method2");
    }
}

在上述示例中,method1调用method2时,由于Synchronized锁的可重入性,线程无需再次获取锁,避免了死锁风险。可重入性是通过锁的计数器实现的,每次进入同步块时计数器加1,退出时减1,当计数器为0时才真正释放锁。

可重入性与继承场景

class Parent {
    public synchronized void operation() {
        System.out.println("Parent operation");
    }
}

class Child extends Parent {
    @Override
    public synchronized void operation() {
        System.out.println("Child before");
        super.operation(); // 调用父类同步方法
        System.out.println("Child after");
    }
}

在继承场景下,子类重写同步方法并调用父类方法时,可重入性保证了锁的正确获取,避免了因多次加锁导致的死锁。

性能优化参数调整

偏向锁相关参数

启用/禁用偏向锁

-XX:+UseBiasedLocking  // 启用偏向锁(JDK6后默认启用)
-XX:-UseBiasedLocking // 禁用偏向锁

偏向锁延迟激活

-XX:BiasedLockingStartupDelay=0 // 启动时立即激活偏向锁(默认延迟4秒)

轻量级锁自旋参数

自旋次数设置

-XX:PreBlockSpin=20 // 设置自旋次数(默认10次)

自适应自旋

-XX:+UseAdaptiveSpinning // 启用自适应自旋(JDK6后默认启用)

自适应自旋会根据前一次自旋的成功情况动态调整自旋次数,提高优化效果。

场景化选择策略

选择Synchronized的场景

  • 简单同步需求:无需复杂锁控制的场景

  • 单线程或低竞争环境:偏向锁和轻量级锁能发挥最佳性能

  • 代码简洁性优先:隐式加锁/解锁减少代码量

  • 与JMM结合的场景:需要利用Synchronized的内存语义保证可见性

选择ReentrantLock的场景

  • 需要公平锁机制:避免线程饥饿

  • 需要可中断锁:响应线程中断

  • 需要条件变量:实现更灵活的等待/通知机制

  • 高竞争环境:ReentrantLock的性能可能更优

  • 需要手动控制锁释放:如配合try-finally确保解锁

选择ReadWriteLock的场景

  • 读多写少的场景:如缓存、配置文件等

  • 需要读写分离:提高读操作的并发性

  • 写入操作耗时较短:避免读锁饥饿

总结

从"重量级锁"到"智能锁"的进化

回顾Synchronized的发展历程,我们可以看到JVM团队在性能优化上的持续努力:

  1. JDK1.0-1.5:仅支持重量级锁,性能较差

  2. JDK6:引入偏向锁、轻量级锁,大幅提升性能

  3. JDK7:优化锁膨胀路径,减少重量级锁的使用

  4. JDK8+:进一步优化偏向锁的获取和撤销流程

这种进化使得Synchronized在无竞争和轻度竞争场景下的性能接近无锁操作,重新成为Java并发编程的首选同步工具之一。

同步机制的选择原则

在实际开发中,选择同步机制应遵循以下原则:

  1. 优先使用Synchronized:对于大多数场景,Synchronized已足够高效,且代码更简洁

  2. ReentrantLock作为补充:当需要公平锁、可中断锁或条件变量时使用

  3. ReadWriteLock谨慎使用:仅在读多写少场景下使用,避免写锁饥饿

  4. 性能测试验证:不同场景下锁的性能表现可能不同,需通过实测确定最优方案

核心知识回顾

  • 锁状态体系:无锁→偏向锁→轻量级锁→重量级锁的状态转换

  • 实现原理:基于JVM监视器锁,通过Mark Word记录锁状态

  • 内存语义:保证共享变量的可见性和操作的有序性

  • 性能优化:偏向锁、轻量级锁、自旋等待等优化手段

  • 应用场景:结合具体业务需求选择合适的同步方案

掌握Synchronized的原理与应用,是成为Java并发编程高手的必经之路。通过深入理解其底层实现和优化机制,我们能够更精准地运用这一强大工具,构建高效、安全的多线程应用。


网站公告

今日签到

点亮在社区的每一天
去签到