Java 并发编程:一文了解 synchronized 的使用

CSDN 2024-08-09 16:05:18 阅读 50

大家好,我是栗筝i,这篇文章是我的 “栗筝i 的 Java 技术栈” 专栏的第 027 篇文章,在 “栗筝i 的 Java 技术栈” 这个专栏中我会持续为大家更新 Java 技术相关全套技术栈内容。专栏的主要目标是已经有一定 Java 开发经验,并希望进一步完善自己对整个 Java 技术体系来充实自己的技术栈的同学。与此同时,本专栏的所有文章,也都会准备充足的代码示例和完善的知识点梳理,因此也十分适合零基础的小白和要准备工作面试的同学学习。当然,我也会在必要的时候进行相关技术深度的技术解读,相信即使是拥有多年 Java 开发经验的从业者和大佬们也会有所收获并找到乐趣。

在当今的多核处理器时代,Java 并发编程变得尤为重要。为了充分利用计算资源,提高程序性能,编写高效、线程安全的并发代码成为每一个 Java 开发者的必修课。在 Java 的并发编程中,<code>synchronized 关键字是最基础也是最常用的工具之一。

synchronized 关键字提供了一种简单且直接的方式来确保代码块或方法在多线程环境下的安全执行。通过对方法或代码块加锁,synchronized 可以防止多个线程同时访问共享资源,从而避免数据不一致的问题。然而,随着应用程序复杂性的增加和对高性能的需求,我们需要对 synchronized 有更深入的理解,以便在实际开发中灵活运用。

本篇文章将全面介绍 synchronized 的使用,从基本语法到锁的内部实现,再到锁的升级机制。无论你是并发编程的新手,还是有一定经验的开发者,这篇文章都将帮助你更好地理解和使用 synchronized,编写出更加高效和健壮的并发程序。

接下来,我们将从 synchronized 的基本概念和语法开始,逐步深入探讨其在 Java 并发编程中的重要角色。


文章目录

1、synchronized 关键字简介2、synchronized 的修饰对象2.1、synchronized 修饰静态方法2.2、synchronized 修饰实例方法2.3、synchronized 修饰代码块

3、对象的内存布局(64位)3.1、Mark Word3.2、Class Pointer3.3、Instance Data3.4、Padding Data

4、Synchronized 锁升级过程4.1、偏向锁4.2、轻量级锁4.3、重量级锁


1、synchronized 关键字简介

在 Java 中,synchronized 关键字用于实现线程之间的同步,以确保多个线程在访问共享资源时不会出现竞态条件。synchronized 可以确保在任何给定时刻,最多只有一个线程可以执行被标记的代码块或方法,从而实现并发安全。

Synchronized 主要有以下三个作用:

原子性(Atomicity):通过互斥访问同步代码块或同步方法,保证同一时间只有一个线程能够执行这段代码,确保了操作的原子性。例如,两个线程同时执行一个同步方法时,只有一个线程能够获得锁并执行,另一个线程必须等待锁释放;

可见性(Visibility):保证线程对共享变量的修改对其他线程是可见的。具体来说,synchronized 会通过 Java 内存模型来实现可见性:当一个线程对变量进行 unlock 操作时,这些操作会同步到主内存中;而当线程对变量进行 lock 操作时,会清空工作内存中的变量值,从主内存中重新加载。这保证了其他线程在访问该变量时能够看到最新的值;

有序性(Ordering):通过 synchronized 解决指令重排序问题。Java 内存模型规定,一个 unlock 操作先行发生(happen-before)于后续对同一个锁的 lock 操作。这意味着,之前的操作(如变量的更新)在获取锁之前必须完成,从而避免了重排序导致的错误。

通过这三个机制,synchronized 能够有效地保证多线程环境下的并发安全。


2、synchronized 的修饰对象

synchronized 关键字可以用于修饰普通方法、静态方法和代码块,以实现线程同步,确保在同一时刻最多只有一个线程执行被锁定的代码段。

2.1、synchronized 修饰静态方法

synchronized 修饰静态方法时,锁定的是当前类的 Class 对象(类)。由于静态方法属于类,而不属于某个具体的对象实例,因此锁定的是整个类。

public class Example { -- -->

public static synchronized void staticMethod() {

// 静态方法体

}

}

2.2、synchronized 修饰实例方法

synchronized 修饰实例方法时,锁定的是当前实例对象。每个对象实例都有自己的一把锁,因此不同实例的同步方法可以同时执行,但同一实例的同步方法不能同时执行。

public class Example {

public synchronized void instanceMethod() {

// 实例方法体

}

}

2.3、synchronized 修饰代码块

synchronized 修饰代码块时,锁定的是 synchronized 括号里指定的对象。同步代码块可以精确地控制锁的作用范围,灵活性更高。同一时刻只有一个线程能够持有指定对象的锁,从而执行代码块内的代码。

public class Example {

private final Object lock = new Object();

public void method() {

synchronized (lock) {

// 同步代码块

}

}

}

需要注意的是,每个锁仅对当前代码块起作用,不会影响其他代码块的执行。因此,锁对象的选择非常重要,要根据具体需求选择合适的对象来进行同步。


3、对象的内存布局(64位)

在 Java 中,synchronized 关键字是基于对象锁来实现的。因此,理解 Java 对象在内存中的布局有助于更好地理解 synchronized 的底层实现。对于一个普通对象来说,它在内存中的布局分为四个部分:

image-20240804215344995

3.1、Mark Word

<code>mark-word 是对象内存布局的核心部分,因为它存储了很多重要的信息。它占用 8 个字节,包含以下信息:

Hashcode:对象的哈希码(通常在对象第一次调用 hashCode() 方法时计算)。锁信息:用于表示对象的锁状态,如无锁状态、偏向锁、轻量级锁和重量级锁。分代年龄:用于表示对象在垃圾回收中的年龄。GC 标志信息:用于垃圾回收标记。

3.2、Class Pointer

class pointer 存储的是该对象的类元数据的引用,通过它可以知道这个对象是哪个类的实例。这个指针也占用 8 个字节。

3.3、Instance Data

instance data 存储的是对象实例的实际数据,包括类中声明的所有实例变量的值。这个部分的大小取决于实例变量的数量和类型。

3.4、Padding Data

padding data 不一定会用到,其主要作用是保证整个对象所占的字节数是 8 的倍数,从而提高内存访问的效率。这样做是为了保证对象在内存中的对齐,以便于快速访问。

以下是一个简化的内存布局示意图:

+-------------------------+

| Mark Word | 8 bytes

+-------------------------+

| Class Pointer | 8 bytes

+-------------------------+

| Instance Data | n bytes

+-------------------------+

| Padding Data | 可选(0-7 bytes)

+-------------------------+

这种布局方式在 64 位 JVM 上尤为重要,因为内存对齐可以显著提升访问速度。了解这些信息有助于我们更深入地理解 synchronized 的工作机制,尤其是在涉及对象锁定和解锁时。、


4、Synchronized 锁升级过程

synchronized 锁有四种状态:无锁、偏向锁、轻量级锁、重量级锁。锁可以升级但不能降级,但偏向锁状态可以被重置为无锁状态。引入锁升级是为了降低获取锁的代价,因为在多数情况下不存在锁竞争,如果每次都要竞争锁会付出很多不必要的成本。以下是锁的升级过程:

4.1、偏向锁

偏向锁在线程第一次获取锁对象时,会在 Java 对象头和栈帧中记录偏向的锁的 ThreadID。当下次线程获取该锁时,会比较 ThreadID 是否一致:

一致(线程1):直接进入,不需要使用 CAS(Compare And Swap)来加锁、解锁。不一致(线程2):检查对象的 ThreadID 线程是否还存活:

存活:代表该对象被多个线程竞争,于是升级成轻量级锁。不存活:将锁重置为无锁状态,锁头重新标记线程为新的 ThreadID(抢占偏向锁失败的线程会触发锁膨胀至轻量级锁)。

如果线程 1 和线程 2 的执行时间刚好错开,那么锁只会在偏向锁之间切换,不会升级为轻量级锁,从而避开获取锁的成本,效率接近无锁状态。

4.2、轻量级锁

当对象被多个线程竞争(或关闭偏向锁功能)时,锁由偏向锁升级为轻量级锁。其他线程会通过 CAS + 自旋 的形式尝试获取锁。JDK 1.7 之后,引入了适应性自旋。简单来说,这次自旋获取到锁了,自旋的次数就会增加;这次自旋没拿到锁,自旋的次数就会减少。

如果后续线程是在持有锁的线程执行结束后抢锁,依然是轻量级锁,因为释放轻量级锁会恢复成无锁状态。如果后续线程是在持有锁的线程执行结束前抢锁,就会触发膨胀成重量级锁。

轻量级锁获取过程:

在代码进入同步块时,如果同步对象锁状态为无锁状态,轻量级锁会构造一个 Lock Record 锁记录,用于存储锁对象目前的 Mark-Word 的拷贝。

public class Example {

public void method() {

synchronized (this) {

// 代码块

}

}

}

拷贝成功后,虚拟机将使用 CAS 尝试将对象头的 Mark-Word 的 Lock-Word(锁记录指针) 指向当前线程 Lock Record 的起始地址,并将 Lock Record 的 owner 指向对象的 Mark-Word:

如果更新成功,线程就拥有了该对象的锁,标志位设置为 00,表示此对象处于轻量级锁定状态。如果更新失败,虚拟机会检查对象的 Lock-Word 是否指向当前线程的 Lock Record。如果是,说明当前线程已经拥有了这个对象的锁,可以继续执行,否则说明多个线程竞争锁,锁升级为重量级。

4.3、重量级锁

当线程的自旋后依然未获取到锁,或者判定多个线程竞争锁时,为避免 CPU 无端耗费,锁由轻量级锁升级为重量级锁。

升级为重量级锁时,锁标志状态值变为 10,此时 Mark-Word 的 Lock-Word 指向重量级锁的指针,获取锁的同时会阻塞其他正在竞争该锁的线程,依赖对象内部的监视器(monitor)实现。monitor 又依赖操作系统底层,需要从用户态切换到内核态,成本非常高。

synchronized 中对 monitor 锁的实现用到了两个指令:monitorentermonitorexit(可通过 javap -verbose Example.class 反汇编查看)。

public class Example {

public synchronized void method() {

// 方法体

}

}

synchronized 在 JVM 里的实现都是基于进入和退出 Monitor 对象来实现方法同步和代码块同步。可以把执行 monitorenter 理解为加锁,执行 monitorexit 理解为释放锁。每个对象维护着一个记录着被锁次数的计数器。未被锁定的对象计数器为 0。

monitorenter:执行 monitorenter 的线程尝试获得 monitor 的所有权,发生以下三种情况之一:

如果 monitor 的计数为 0,线程获得 monitor 并将计数设置为 1,线程成为 monitor 的所有者。如果线程已经拥有了这个 monitor,则重新进入并累加计数。如果其他线程已经拥有了这个 monitor,当前线程会被阻塞,直到计数变为 0,代表 monitor 已被释放,当前线程再次尝试获取 monitor。 monitorexit:monitorexit 将 monitor 的计数器减 1,直到减为 0,表示 monitor 已被释放,没有任何线程拥有它,其他等待的线程可以再次尝试获取 monitor。

在底层,monitor 依赖操作系统的 MutexLock(互斥锁)实现,因此重量级锁也称为互斥锁。




声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。