Java--剖析synchronized

1. synchronized 的特性

1.1原子性

原子性是指:一段操作要么全部同时完成,要么就不完成,中间过程不会被线程切换打断

按道理来说都是这样的呀,为什么原子性还是个特性呢?原因就是CPU执行的不是语句,而是指令。

就比如:count++这个语句,我们就认为自增一下,0->1这种。但是实际上却有三步:

  1. 从主内存读取 count 到寄存器(读)
  2. count+1(加)
  3. 写回主内存(写)

但是此时又会出现新的问题:假设一个场景,先有一个count(初始值为0),需要线程A和B都对count自增,那么按照我们上面的步骤,线程A先从主内存取出count的值(0),然后对0自增一次成为1,但在这个时候线程B读取count的值(0),然后B自增完成了1,A保存,B保存,最后保存了的值还是1(期望值是2,因为执行了两次自增操作)。这就是因为等多个线程同时进入了一个非原子操作,并且指令执行被穿插。

这里来一个代码案例观察观察~

java 复制代码
class Counter{
    int count = 0;
    public void add(){
        count++;
    }
}

public class t {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

            Thread t1 = new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    counter.add();
                }
            });

        Thread t2 = new Thread(() -> {
            for (int j = 0; j < 10000; j++) {
                counter.add();
            }
        });

            t1.start();
            t2.start();
            t1.join();
            t2.join();
            System.out.println(counter.count);
    }
}

上面就是两个线程都对count进行自增10000次,理论上来说结果应该是20000,但是结果却不是20000,而是在大概在10000-20000之间浮动:

这也就是上面所说的多个线程没有遵守原子性操作,导致穿插执行(随机调度)。

如何让 synchronized 保证原子性呢?

主要就是将多条指令变成临界区独占执行。在底层原理中,每个Java对象都有一个对象头(Object Header),其中Mark Word可以指向一个Monitor对象(操作系统层面的互斥结构)。

Monitor内部维护了:

  • _owner:当前持有锁的ID
  • _EntryList:等待获取锁的线程队列

当线程执行synchronized 块时,JVM会生成 monitorenter 和 monitorexit 指令。monitorenter 尝试将_owner 设置为当前线程--同一时刻只有一个线程能成功。其他线程必须等待。这样就会让count++的三条指令在执行期间不会被任何其他线程插入,因此变成了不可分割的原子操作。

java 复制代码
class Counter {
    int count = 0;
    public synchronized void add() {
        count++;
    }
}

加上synchronized关键字后,count++就会从可并发执行转换为串行执行。

详细讲解:

什么是HotSpot?

你可以将HotSpot理解为Java程序的"高性能发动机"。它并非一个理论规范,而是一个具体的、业界广泛使用的Java虚拟机实现。

它的核心思想是"自适应编译 "和"热点代码探测":

  • 混合执行模式 :HotSpot采用"解释器+JIT(即时)编译器"的混合模式工作。

  • 探测"热点":程序启动时,代码先由解释器逐行执行。同时,HotSpot会默默记录下各段代码的执行频率。那些被高频调用的方法或循环体,就被标记为"热点代码"。

  • 编译与优化:一旦某段代码被判定为"热点",HotSpot就会启动JIT编译器,将其动态编译成本地机器码,并执行优化。这段编译后的代码会直接被缓存起来,下次执行时无需再次编译,从而实现性能飞跃。

这种模式在启动速度 (得益于解释器)和运行效率(得益于JIT编译)之间取得了极佳的平衡。

什么是Monitor?

Monitor(监视器/管程)是Java并发编程的基石,它确保了多线程环境下对共享资源的安全访问。

1. 概念:一把看不见的"锁"

你可以把Monitor理解成与每个Java对象绑定的一把看不见的、排他性的"锁"

  • 互斥性 :任何一个时刻,最多只有一个线程可以持有(或者说"拥有")一个对象的Monitor。

  • synchronized的本质 :当你用synchronized关键字修饰一段代码或方法时,JVM的底层操作正是让线程在进入前获取(monitorenter 关联对象的Monitor,在退出后释放(monitorexit。只有成功获取到Monitor的线程,才能进入临界区执行代码。

2. 实现:ObjectMonitor 结构体

在HotSpot的C++源码中,Monitor的具体实现是一个名为 ObjectMonitor 的结构体。它的核心属性就像一张详细记录锁状态的名册:

属性 作用描述
_owner 指向当前拥有该Monitor的线程的指针。
_EntryList 阻塞队列 。存放那些暂时没抢到锁、被阻塞的线程。
_WaitSet 等待队列 。存放那些曾经拥有锁,但主动调用 wait() 方法进入等待状态的线程。
_cxq 竞争队列。新来的线程抢锁失败,会先进入这个队列,是 _EntryList 的"预备队"。
_recursions 记录同一个线程重入同一把锁的次数。对于可重入锁的实现至关重要。
_count 一个计数器,其值大约为 _EntryList 和 _WaitSet 中线程数量之和。
3. 工作机制:线程的"调度中心"

当一个线程请求一把已经被占用的锁时(即重量级锁状态),Monitor的工作流程如下:

  1. 尝试获取 :线程尝试进入 synchronized 块,JVM执行 monitorenter 指令。

  2. 加入队列 :如果锁已被占用,当前线程会被封装成一个 ObjectWaiter 对象,并被加入到 _cxq(竞争队列) 的头部。

  3. 竞争或阻塞:加入队列后,线程会短暂进行自旋,再次尝试获取锁。如果依然失败,线程将被挂起(park),等待被唤醒。

  4. 唤醒与晋升 :当持有锁的线程释放锁后,HotSpot会唤醒 _cxq_EntryList 中的等待线程,并让它们重新竞争锁。

  5. wait()notify() :拥有锁的线程如果调用 wait(),它会释放锁,_owner 被置空,自身进入 _WaitSet 等待。当其他线程调用 notify() 时,会从 _WaitSet 中随机唤醒一个线程,将其移入 _EntryList 重新竞争锁。

4. 状态演变:从"轻"到"重"的锁升级

为了提高性能,Monitor的状态并非一蹴而就,而是会随着线程竞争情况逐步 "升级",且这个过程是单向的,不可逆。Java 6及以后,锁主要有四种状态:

  1. 无锁:初始状态,没有线程竞争。

  2. 偏向锁:当一个线程反复获取同一把锁时,JVM会偏向它,省去CAS操作,性能极高。

  3. 轻量级锁:当第二个线程开始竞争,偏向锁升级。线程通过自旋(CAS)尝试获取锁,避免线程阻塞和唤醒的开销。

  4. 重量级锁 :当竞争激烈,自旋超过一定次数后,升级为重量级锁。未获得锁的线程会直接进入 _EntryList 阻塞,由操作系统内核进行线程调度。

注意 :与重量级锁关联的 ObjectMonitor 对象,也并非一创建就存在,而是在锁首次膨胀为重量级锁时被延迟创建并关联到对象上的。

完整流程

用一个简单的代码块,把上面所有知识串联起来:

java 复制代码
public class Example {
    private static final Object lock = new Object();

    public static void main(String[] args) {
        synchronized (lock) {
            System.out.println("Hello, Monitor!");
        }
    }
}
  1. 字节码层面 :编译后,synchronized 代码块前后会生成 monitorentermonitorexit 指令。

  2. JVM执行 :当程序运行到 monitorenter 指令时,JVM会定位到 lock 对象。

  3. 查看对象头 :JVM会检查 lock 对象的对象头中的 Mark Word 字段。

  4. 锁状态判定

    • 如果 Mark Word 显示为无锁 ,JVM会通过CAS操作尝试将锁设置为轻量级锁 ,并将 Mark Word 指向线程栈中的锁记录。

    • 如果锁已经是轻量级锁 且被当前线程持有,则执行锁重入 ,计数器_recursions加1。

    • 如果发生锁竞争(另一线程尝试获取),JVM会执行锁膨胀

      • lock 对象创建一个 ObjectMonitor 对象。

      • lock 对象头的 Mark Word 更新为指向这个 ObjectMonitor 的指针,锁标志位变为 重量级锁

      • 竞争失败的线程被封装成 ObjectWaiter,进入 ObjectMonitor_cxq 队列,并最终被挂起。

  5. 执行临界区 :成功获取锁的线程执行 System.out.println(...) 代码。

  6. 退出并释放 :当执行到 monitorexit 指令时,线程会退出临界区,并调用 ObjectMonitor::exit() 释放锁。这包括将 _owner 置为 null,并根据策略从 _EntryList_cxq 中唤醒下一个等待线程。

总结

synchronized 通过对象头中的Monitor 实现互斥锁,保证同一时刻只有一个线程执行临界区,从而将多条执行绑定为一个不可分割的原子操作。

1.2 可见性

可见性指的是:一个线程对变量的修改能够被其他的线程立即(或者在可预期的时间内)看到。

出现不可见的原因是:CPU缓存+寄存器+编译优化。现代川普的运算速度远高于内存访问速度,因此每个核心都有自己的L1/L2缓存。线程执行时,会将变量从主内存复制到自己的工作内存(抽象概念,对应CPU缓存和寄存器),并优先从缓存中读写,如果没有挺不错是,线程永远不会主动去内存中重新读取,因为从缓存读取会快很多很多~

主内存

↑↓

线程A工作内存(缓存) 线程B工作内存(缓存)

就比如一个场景:

java 复制代码
boolean flag = false;
// 线程A:flag = true;   // 仅写入自己缓存,未刷回主内存
// 线程B:while(!flag){} // 一直读取自己缓存中的 false → 死循环

修改后的结果没有存入内存,而是存入了自己的缓存,这样就会导致线程B永远看不到变化

synchronized 如何解决这个问题呢?

关键点不是加锁,而是锁的获取与释放伴随内存同步

  • 锁释放:将工作内存中修改的变量刷新到主内存中
  • 锁获取:是本地缓存失效,迫使后续读操作从主内存中重新拉取

这背后其实是JMM的 happens-before后续对同一个锁的lock操作。

这意味着:线程A在释放锁之前修改的所有共享变量,在线程B获得同一个锁之后,B一定能看到A的修改(及时物理上变量还留在缓存,JVM也会通过内存屏障强制同步)

java 复制代码
class Test {
    static boolean flag = false;
    static final Object lock = new Object();

    public static void main(String[] args) {
        new Thread(() -> {
            while (true) {
                synchronized (lock) {
                    if (flag) break;
                }
            }
            System.out.println("结束");
        }).start();

        new Thread(() -> {
            synchronized (lock) {
                flag = true;
            }
        }).start();
    }
}

这段代码中,线程B释放锁时把flag = true刷回主内存;线程A每次获取锁时都会强制从主内存读取最新的flag,从而看到变化。

synchronized 在锁释放和再次加锁之间建立了happens-before 关系。具体而言,一次 synchronized 代码块或方法的退出(释放锁)与同一监视器锁下一次进入(获取锁)之间有严格的先后关系,这保证了前者对共享变量的写入对于后者是可见的。JMM 规定:"对一个监视器的解锁发生于对同一个监视器的随后的加锁之前"。因此,只要程序正确同步(无数据竞争),执行结果将符合顺序一致性,不需要考虑重排序对正确性的影响。

1.3 有序性

有序性指的是:程序执行顺序复合代码书写顺序。即:写在前面的代码应看上去先执行,写在后面的后执行。

既然有有序性这个概念那么必然会有与之对应的无序性。发生无序性的原因是编译器优化+CPU指令重排序。为了提升性能,编译器和CPU可能会在不影响单线程执行结果的前提下调整指令执行顺序。

就比如说:a=1,b=2 实际CPU可能限制性b=2再执行a=1。在单线程情况下最终结果一样,没有什么影响。但是在多线程下,重排可能导致严重后果比如:其他线程读到半初始化状态。

经典问题:双重检查锁单利失效:

java 复制代码
instance = new Singleton();

这条语句再JVM中实际上可能分解为三个步骤:

  1. 分配内存空间
  2. 初始化对象(调用构造器)
  3. 将instance 引用指向这块内存

编译器/CPU可能重排为:1->3->2,此时另一个线程判断instance != null,但是返回来的对象尚未执行构造器,使用时会出错。

synchronized 如何保证有序性?

synchronized本质上通过内存屏障来禁止重排序。

当JVM遇到synchronized块时,会在临界区的入口和出口插入特定类型的内存屏障(如 StoreStore、StoreLoad、LoadLoad),其中最关键的StoreLoad屏障会:

  • 禁止屏障上方的写造作与下方的读操作重排
  • 强制将写缓冲区的数据刷到缓存/主内存
屏障指令 作用(防止重排序) 典型场景
StoreStore 屏障前的所有写操作(Store)先于屏障后的写操作执行(写-写有序) 保证写操作按顺序提交到主存,例如普通变量写后跟随 volatile 写
LoadLoad 屏障前的所有读操作(Load)先于屏障后的读操作执行(读-读有序) 保证读操作看到的数据版本一致,例如 volatile 读后跟随普通读
StoreLoad 屏障前的所有写操作先于 屏障后的读操作执行,且写结果对所有处理器可见(写-读有序,最重量级 实现完全的顺序一致性,例如 volatile 写后立即读另一个 volatile 变量
  • StoreStore:写-写屏障,保证写入顺序。

  • LoadLoad:读-读屏障,保证读取顺序。

  • StoreLoad:写-读屏障,最严格的顺序保证 + 可见性保证,开销大。

效果:临界区的代码不会被重排序放到临界区外,临界区外的代码也不会被重排序到临界区内。


java 复制代码
synchronized(lock) {
    a = 1;
    b = 2;
}

这段代码中,JVM保证:在释放锁之前,a=1一定在b=2之前对外部线程可见,不会出现b=2先写的情况。

synchronized通过内存屏障机制禁止指令重排序,从而保证了临界区代码执行的有序性。

三大特性总结

synchronized 做了三件事:

  1. 加锁(Monitor互斥) → 保证原子性

  2. 内存同步(缓存刷新/失效) → 保证可见性

  3. 内存屏障(禁止重排) → 保证有序性

synchronized 本质是通过对象头中的 Monitor 实现互斥锁,结合 JMM 的 happens-before 规则(unlock → lock)以及内存屏障机制,共同保证了原子性、可见性和有序性。它既是互斥锁,也是同步屏障。

2. synchronized的使用方式

synchronized 可以用在啊三个地方:修饰实例方法、修饰静态方法、修饰代码块。不同的用法对应不同的锁对象

2.1 修饰实例方法

语法:

java 复制代码
public synchronized void method() {
// 方法体
}

锁对象:当前实例对象,也就是this。

效果:同一时刻,同一个实例对象的多个线程只能有一个进入该方法。不同实例之间互不影响,因为是不同的对象。

java 复制代码
public class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }
}

等价于使用 synchronized 代码块包裹整个方法体:

java 复制代码
public void increment() {
    synchronized(this) {
        count++;
    }
}

多个线程同时调用的情况:

java 复制代码
public class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }

    // 测试多线程并发
    public static void main(String[] args) throws InterruptedException {
        final Counter counter = new Counter();
        final int threadCount = 1000;       // 线程数量
        final int incrementsPerThread = 100; // 每个线程增加次数

        Thread[] threads = new Thread[threadCount];

        // 启动所有线程
        for (int i = 0; i < threadCount; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < incrementsPerThread; j++) {
                    counter.increment();
                }
            });
            threads[i].start();
        }

        // 等待所有线程结束
        for (Thread t : threads) {
            t.join();
        }

        // 预期结果:threadCount * incrementsPerThread
        int expected = threadCount * incrementsPerThread;
        int actual = counter.getCount();

        System.out.println("Expected count: " + expected);
        System.out.println("Actual count  : " + actual);
        System.out.println("Correct? " + (expected == actual));
    }
}
  • 同一个 Counter 实例上的 increment()getCount() 都使用了 synchronized 实例方法。

  • 这意味着这两个方法都锁定 this(即当前 Counter 对象)。同一时刻,只有一个线程 能进入 increment()getCount()

  • 因此 count++ 操作(读-改-写)成为原子操作,不会出现多个线程交错执行导致的更新丢失。

  • synchronized 保证了进入同步块时从主内存重新读取变量,退出同步块时将修改刷新回主内存。

  • 所以一个线程对 count 的修改对其他线程立即可见。

但是如果去掉synchronized关键字的话会导致count++变为非原子操作~~

还可以增加一个演示,让部分线程调用 increment(),部分线程调用 getCount(),验证互斥依然生效:

java 复制代码
// 10个写线程 + 10个读线程
for (int i = 0; i < 10; i++) {
    new Thread(() -> {
        for (int j = 0; j < 1000; j++) counter.increment();
    }).start();
    new Thread(() -> {
        for (int j = 0; j < 1000; j++) counter.getCount(); // 读操作也会阻塞写操作
    }).start();
}

由于读方法也加了 synchronized,读线程和写线程不能同时执行,这可能会降低并发吞吐量,但保证了强一致性 (每次读取到的都是最新值)。如果读操作允许脏读,可以只对写方法加锁,读方法不加锁(但需保证可见性,例如使用 volatile)。

场景 是否有锁 结果正确性 并发性能
两个方法都加 synchronized 总是正确 较低(读写互斥)
只有 increment 加锁 部分 可能出现脏读 较高(读并发)
都不加锁 数据竞争,结果错误 最高但不安全

2.2 修饰静态方法

语法:

java 复制代码
public static synchronized void staticMethod() {
    // 方法体
}

锁对象:当前类的Class对象,即ClassName.class。

效果:同一时刻,整个应用程序中所有线程只能有一个进入该静态方法。因为所有实例共享同一个Class对象。

java 复制代码
public class SharedResource {
    private static int sharedCount = 0;

    public static synchronized void incrementShared() {
        sharedCount++;
    }

    public static synchronized int getSharedCount() {
        return sharedCount;
    }
}

这个代码也等价于:

java 复制代码
public static void incrementShared() {
    synchronized(SharedResource.class) {
        sharedCount++;
    }
}

在这个代码中,对于static synchronized方法,锁对象是当前类的Class对象,即SharedResource.class。

  • 每个对象在JVM中都有且仅有一个Class对象(类加载器生成)
  • 当线程执行incrementShared()时,必须获得SharedResource.class 这把锁

并且在这个程序案例中,是有两个方法公用一把锁的(incrementShared() 和getSharedCount()都用 static synchronized 修饰)因此它们是共用同一把锁的。

这样做的话,当线程A执行incrementShared() 时,线程B无法执行getShared(),因为两者需要同一把锁~ 这就保证了读写互斥,读操作也能看到最新的值,不会读到中间状态。

java 复制代码
// 线程1
SharedResource.incrementShared();   // 持有类锁

// 线程2
int v = SharedResource.getSharedCount(); // 必须等待线程1释放锁

与实例同步方法对比:

特性 静态同步方法 实例同步方法
锁对象 类的 Class 对象 当前实例 this
保护的数据 静态成员变量 实例成员变量
多个实例是否互斥 是(所有实例共享同一个类锁) 否(不同实例锁不同)
典型使用场景 计数器、全局配置、单例模式等 普通对象的线程安全方法
java 复制代码
SharedResource obj1 = new SharedResource();
SharedResource obj2 = new SharedResource();

// 静态同步方法:obj1 和 obj2 调用时会互斥(因为锁是 SharedResource.class)
obj1.incrementShared();  // 持有类锁
obj2.incrementShared();  // 阻塞,直到上一个释放

// 实例同步方法(假设有):obj1 和 obj2 调用时不会互斥(锁分别是 obj1 和 obj2)

并且,synchronized是可重入锁,同一个线程可以重复进入用一个锁对象保护的任何同步方法。

java 复制代码
public static synchronized void methodA() {
    methodB();   // 允许,因为线程已经持有 SharedResource.class 锁
}

public static synchronized void methodB() {
    // ...
}

原理:Monitor 记录持有锁的线程ID和重入次数,每次进入加1,退出减1,只有减到0才是真正的释放锁。


根据JMM规则:unlock 操作 happens-before 后续对同一锁的 lock 操作如下:

  • 线程A执行 incrementShared() 释放锁之前,对 sharedCount 的修改会刷新岛主内存中
  • 线程B执行 getSharedCount() 获取锁时,会使本地缓存失效,强制从主内存中读取最新值

因此可以不需要额外使用volatile 修饰 sharedCount (当然加上也可以~)


不过也有部分潜在的问题:

  1. 两个方法使用同一把锁的情况下,读操作也会阻塞写操作,如果读操作非常频繁,会降低并发性能。可以使用 ReentrantReadWriteLock ,允许多个读线程并发,写线程互斥。

  2. 由于SharedResource.class 是公开的,任何凄然代码都可以通过synchronized(SharedResource.class)获取这把锁,可能会导致外部死锁或意外阻塞。

    java 复制代码
    // 外部恶意或失误代码
    synchronized (SharedResource.class) {
        Thread.sleep(10000);   // 阻塞所有使用 SharedResource 静态同步方法的线程
    }

    可以通过使用内部私有锁对象,但静态方法无法直接使用实例锁对象,也可以改为静态内部类持有锁:

    java 复制代码
    public class SharedResource {
        private static int sharedCount = 0;
        private static final Object LOCK = new Object();  // 私有锁,外部无法获取
    
        public static void incrementShared() {
            synchronized (LOCK) {
                sharedCount++;
            }
        }
    }

    这样既保证互斥,又避免了锁风险。

这里再给出一个代码案例:

java 复制代码
public class Test {
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                SharedResource.incrementShared();
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                SharedResource.incrementShared();
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println(SharedResource.getSharedCount()); // 始终输出 2000
    }
}

这个代码案例在没有同步的情况下,结果会小于2000,但是在使用静态方法同步之后结果就正常了~

核心点 说明
锁对象 SharedResource.class,全局唯一
保护的数据 静态变量 sharedCount
互斥范围 所有线程,所有实例,所有静态同步方法之间互斥
可重入 支持,同一线程可再次进入该类其他静态同步方法
可见性 由 happens-before 规则保证,释放锁强制刷新,获取锁强制重新读取
缺点 粒度粗(读写互斥),锁对象暴露
改进建议 读多写少时改用 ReadWriteLock;避免锁暴露使用 private static final 锁对象

2.3 修饰代码块

synchronized 修饰代码块是这些方法中最灵活的~

语法:

java 复制代码
synchronized(lockObject) {
    // 临界区代码
}

锁对象:可以是任何Java对象 (推荐使用 private final 的专用锁对象)。

只有在lockObjec 锁的线程才能进入代码块,多个线程使用同一个lockObject 时才会互斥,使用不同的 lockObject 则并发执行。

比如:

java 复制代码
public class DataHolder {
    private final Object lock = new Object();  // 专用锁对象
    private int value = 0;

    public void setValue(int v) {
        synchronized(lock) {
            value = v;
        }
    }

    public int getValue() {
        synchronized(lock) {
            return value;
        }
    }
}

推荐使用 private final 修饰的专用锁对象有以下两点原因:

  1. 防止外部线程意外持有锁导致死锁或者性能问题
  2. final 能确保锁对象引用不变,避免锁对象被意外替换造成并发混乱

锁对象的选择:

使用方式 锁对象 适用场景
修饰实例方法 this(当前实例) 保护实例级别的成员变量
修饰静态方法 Class 对象 保护静态成员变量
修饰代码块 自定义对象 粒度更细,性能更好
  • 不要使用 String 常量或 Integer 等可能被 JVM 缓存的对象作为锁,因为不同地方的相同字面量可能指向同一个对象,意外造成不必要的互斥。

  • 不要使用可变的字段作为锁对象,否则锁对象被修改后,不同的线程看到的锁对象不一致,互斥失效。

推荐:

java 复制代码
private final Object lock = new Object();

2.4 可重入性

synchronized 是一个可重入锁,即同一个线程已经拥有某个对象的锁,可以再次进入该对象上的其他synchronized 方法/块,而不会阻塞自己

java 复制代码
public synchronized void methodA() {
    methodB();   // 同一线程可以进入,因为已经持有锁
}

public synchronized void methodB() {
    // ...
}

原理: Monitor 内部会记录 _owner 线程和 _recursions(重入次数)。每次重入计数加 1,每次退出减 1,计数为 0 时释放锁。

小结

  • 不可中断 :如果一个线程等待锁,无法被中断(不像 Lock 可以 tryLock)。等待过程中线程一直处于 BLOCKED 状态。

  • 非公平锁:等待锁的线程不会按先来后到获得锁,而是由操作系统调度决定。

  • 锁升级(JVM 优化) :在 JDK 1.6 之后,synchronized 不再是单纯的重量级锁,会经历"无锁 → 偏向锁 → 轻量级锁 → 重量级锁"的升级过程,以降低加锁开销。

代码示例(synchronized 实现线程安全的计数器)

java 复制代码
public class SafeCounter {
    private int count = 0;
    private final Object lock = new Object();

    // 方式一:同步代码块
    public void increment() {
        synchronized(lock) {
            count++;
        }
    }

    // 方式二:同步实例方法(锁是 this)
    public synchronized int getCount() {
        return count;
    }

    // 方式三:同步静态方法(锁是 SafeCounter.class)
    private static int staticCount = 0;
    public static synchronized void incrementStatic() {
        staticCount++;
    }
}

总结:什么时候用哪种 synchronized?

  • 保护实例变量且整个方法都需要同步 → 实例方法

  • 保护静态变量 → 静态方法

  • 只需要同步方法中的一小段代码 → 代码块 + 专用锁对象(性能更好)

  • 多个方法需要共享同一个锁对象(但不是整个 this) → 代码块 + 自定义锁对象

通过合理选择 synchronized 的使用方式,可以在保证线程安全的同时,尽可能提升并发性能。


3. 锁策略

在 JDK 1.6 之前,synchronized 是一个纯粹的重量级锁 ,依赖于操作系统的互斥量(mutex),线程阻塞和唤醒需要用户态到内核态的切换,开销很大。从 JDK 1.6 开始,JVM 对 synchronized 进行了大量优化,引入了锁升级 机制和多种锁策略,使得 synchronized 的性能在不竞争或轻度竞争的场景下接近无锁。

3.1 锁升级过程(无锁 → 偏向锁 → 轻量级锁 → 重量级锁)

每个 Java 对象的对象头中有一个 Mark Word,其中记录了锁的状态。锁的升级是单向的,只能由低级别向高级别膨胀,不能降级。

(1)无锁状态

对象刚创建时,没有线程竞争锁,Mark Word 处于无锁状态。

(2)偏向锁

核心思想: 如果一个线程多次获取同一个锁,那么锁会偏向于这个线程,消除该线程后续重入时的同步开销。

工作方式:

当线程第一次获取锁时,JVM 将 Mark Word 中的线程 ID 设置为当前线程,并将锁标志位设置为"偏向锁"。之后该线程再次进入同步块时,只需比较 Mark Word 中的线程 ID 是否是自己,如果是则直接进入,无需任何 CAS 操作或互斥。

偏向锁的撤销:

当其他线程尝试竞争该锁时,偏向锁需要撤销。撤销操作需要在全局安全点(所有线程停止执行)进行,开销较大。因此,如果应用程序中大部分锁始终由同一个线程持有,偏向锁能显著提升性能;如果锁竞争激烈,偏向锁的频繁撤销反而会降低性能。JVM 参数可以关闭偏向锁:-XX:-UseBiasedLocking

(3)轻量级锁

核心思想: 当偏向锁被撤销,或者没有开启偏向锁,且当前没有其他线程竞争锁时,JVM 会使用轻量级锁。轻量级锁通过 CAS(Compare And Swap) 操作在用户态尝试获取锁,避免进入内核态。

工作方式:

线程在执行同步块之前,会在自己的栈帧中创建一个锁记录(Lock Record),并将对象头的 Mark Word 复制到锁记录中(称为 Displaced Mark Word)。然后通过 CAS 尝试将对象头的 Mark Word 替换为指向锁记录的指针。如果成功,该线程获得轻量级锁;如果失败,说明有其他线程竞争,轻量级锁会自旋(见下文)一定次数,若仍无法获得锁,则膨胀为重量级锁。

(4)重量级锁

核心思想: 当锁竞争激烈,自旋等待超过一定次数(或自旋线程数超过 CPU 核数的一半)时,轻量级锁会升级为重量级锁。此时线程阻塞,通过操作系统的互斥量实现,不再使用 CAS。

工作方式:

重量级锁对应的就是之前提到的 Monitor 对象,_owner 记录持有锁的线程,其他线程进入 _EntryList 阻塞队列,等待被唤醒。重量级锁虽然开销大,但能保证在严重竞争下不会浪费 CPU 资源。

2. 自旋锁(Spin Lock)

为什么需要自旋锁?

当线程无法获得轻量级锁时,如果直接阻塞线程,需要用户态到内核态的切换,开销很大。而很多时候,持有锁的线程很快就会释放锁。因此,JVM 会让等待的线程自旋(执行一段空循环),循环等待锁释放。如果自旋期间锁被释放,线程就能立刻获得锁,避免了线程阻塞和唤醒的开销。

自旋的缺点: 如果锁被长时间占用,自旋会白白消耗 CPU 资源。因此 JVM 会自适应调整自旋次数(自适应自旋),例如根据上一次自旋是否成功来决定下一次自旋的时间。

自适应自旋:

如果一次自旋成功,JVM 认为这次自旋也可能成功,会适当增加自旋次数;反之,如果经常自旋失败,则会减少自旋次数甚至直接升级为重量级锁。

3. 锁消除(Lock Elimination)

核心思想: JVM 通过逃逸分析 (Escape Analysis)判断某个锁对象是否只被一个线程访问(即不会逃逸出当前线程)。如果确定不会逃逸,JVM 会消除这个锁,因为不存在竞争。

示例:

java

复制代码
public void appendString(String s1, String s2) {
    StringBuffer sb = new StringBuffer();  // StringBuffer 的方法是同步的
    sb.append(s1);
    sb.append(s2);
}

StringBufferappend 方法使用了 synchronized,但 sb 对象只在方法内部使用,不会被其他线程访问。JVM 在运行时会将这两个同步操作优化掉,直接变成普通的方法调用,提高性能。

4. 锁粗化(Lock Coarsening)

核心思想: 如果 JVM 检测到同一个线程连续多次对同一个对象进行加锁解锁操作,且中间没有其他线程竞争,JVM 会将这些锁操作合并为一个更大的同步块,减少锁的获取和释放次数。

示例:

java

复制代码
public void test() {
    synchronized(lock) {
        // do something
    }
    synchronized(lock) {
        // do something else
    }
}

JVM 可能将其粗化为:

java

复制代码
public void test() {
    synchronized(lock) {
        // do something
        // do something else
    }
}

锁粗化减少了多次进入/退出同步块的开销。

5. 锁策略总结对比

锁类型 实现方式 适用场景 开销
偏向锁 对象头记录线程 ID 单线程反复获取同一锁 几乎为零
轻量级锁 CAS + 自旋 轻度竞争,锁持有时间短 较低(用户态)
重量级锁 操作系统互斥量 激烈竞争,锁持有时间长 高(内核态)
自旋锁 忙循环等待 配合轻量级锁,锁很快释放 消耗 CPU
锁消除 逃逸分析后删除锁 锁对象不逃逸 无开销
锁粗化 合并连续的同步块 同一线程反复加锁解锁 减少加锁次数

6. 总结

JDK 1.6 之后,synchronized 不再是一成不变的重量级锁,而是通过锁升级 (偏向锁 → 轻量级锁 → 重量级锁)、自旋锁锁消除锁粗化 等一系列优化策略,在不改变语义的前提下大幅提升了并发性能。理解这些策略有助于我们写出更高效的并发代码,同时也说明了为什么在轻度竞争下 synchronized 的性能常常优于显式的 ReentrantLock

以上就是本篇全部内容啦~咱们下期再见~~

相关推荐
我是唐青枫2 小时前
C#.NET Monitor 与 Mutex 深入解析:进程内同步、跨进程互斥与使用边界
开发语言·c#·.net
ou.cs2 小时前
c# 信号量和锁的区别
开发语言·c#
ayt0072 小时前
Netty AbstractNioChannel源码深度剖析:NIO Channel的抽象实现
java·数据库·网络协议·安全·nio
Gofarlic_OMS2 小时前
装备制造企业Fluent许可证成本分点典型案例
java·大数据·开发语言·人工智能·自动化·制造
码王吴彦祖2 小时前
顶象 AC 纯算法迁移实战:从补环境到纯算的完整拆解
java·前端·算法
Freak嵌入式2 小时前
MicroPython LVGL基础知识和概念:显示与多屏管理
开发语言·python·github·php·gui·lvgl·micropython
yu85939582 小时前
matlab雷达信号与干扰的仿真
开发语言·matlab
前进的李工2 小时前
LangChain使用AI工具赋能:解锁大语言模型无限潜力
开发语言·人工智能·语言模型·langchain·大模型
yugi9878382 小时前
C# 串口下载烧写BIN文件工具
开发语言·c#