Java并发理论
Java并发理论
线程之间如何通信及线程之间如何同步?
在 Java 并发编程中,线程之间的通信通常依赖共享内存模型,通过对共享变量的读写完成信息传递。通信是隐式发生的,而同步则是显式控制的过程,用于协调线程执行顺序和数据可见性,常见方式包括 synchronized
、Lock
和 volatile
等。
在共享内存模型下,线程之间并不是通过消息显式传递来交换数据,而是通过共享变量间接通信。Java 中线程通信的"隐式"特性,意味着变量值的变化需要借助同步手段才能被其他线程感知,否则就可能因为 CPU 缓存、编译器优化等原因导致"看不见"最新的数据。这正是内存可见性问题的根源。Java 提供的同步机制如 synchronized
、Lock
、volatile
等,不仅用来互斥访问共享资源,更重要的是用于建立happens-before 关系,从而保证写入操作对其他线程可见。
例如,当一个线程释放锁后,另一个线程获取同一把锁,那么前者对共享变量的写入对后者是可见的。再如使用 volatile
修饰变量时,写操作立即刷新到主内存,读操作也会直接从主内存读取,从而避免了线程本地缓存不一致的问题。理解通信与同步的配合关系,是编写高质量并发程序的基础,既能保障正确性,也有助于提升系统性能。
Happens-Before 原则
Happens-before 是 Java 内存模型中用于定义操作执行顺序和可见性的核心规则。若操作 A happens-before 操作 B,则说明 A 的执行结果对 B 可见,且 A 的执行必须先于 B。
Java 内存模型为了应对指令重排序和多线程带来的内存不可见问题,定义了 happens-before 原则。这一原则是并发语义中最关键的判断依据,它规定了某个操作(如变量写入)对另一个操作(如变量读取)的可见性与执行先后。常见的 happens-before 场景包括:线程内的操作顺序天然满足 happens-before;对同一个锁的释放先于随后获取该锁的操作;volatile
变量的写操作先于后续的读操作;线程启动前的所有操作对该线程可见,等等。该原则的本质是:不违反这些规则的前提下,JVM 和 CPU 可以自由进行优化和指令重排。而开发者只有在充分理解这些语义保障后,才能在多线程环境下编写出既正确又高效的程序。比如,在双重检查锁(DCL)中,如果没有 volatile 保证重排序可见性,可能会导致对象未初始化完成就被其他线程访问,这种问题就属于违反了 happens-before 的语义保障。
Java 怎么进行并发控制?
Java 提供两大类并发控制手段:悲观锁和乐观锁。悲观锁如 synchronized
和 ReentrantLock
,通过互斥锁控制访问;乐观锁如 CAS 原语及原子类,利用无锁机制提高并发效率。
Java 在并发控制上提供了丰富的技术选择,满足不同粒度与性能要求。悲观锁代表如 synchronized
、ReentrantLock
等,它们在设计上假定冲突是常态,因此通过加锁机制来避免并发访问问题。synchronized 是最基本的内置锁,底层由对象的 monitor 监视器实现,配合 JVM 字节码指令 monitorenter 和 monitorexit 实现线程互斥。其使用方式包括修饰实例方法、静态方法或同步代码块。JDK 1.6 以后还引入偏向锁、轻量级锁等机制对其做了性能优化。而 ReentrantLock 是 java.util.concurrent 包中的显式锁,基于 AQS(AbstractQueuedSynchronizer)构建,支持更多功能如可中断获取、公平锁、条件变量等,是复杂并发控制的首选工具。
与悲观锁不同,乐观锁假设冲突较少,因此采用 CAS(比较并交换)机制,如 AtomicInteger 等类来保证原子性操作,避免了线程阻塞。在高并发场景下,乐观锁由于其非阻塞特性往往能获得更好性能,但不适合对一致性要求极高的复合逻辑。因此,悲观与乐观锁应结合具体场景灵活选用。
synchronized 关键字
synchronized
是 Java 中用于多线程并发控制的关键字,能够对方法或代码块加锁,确保同一时刻最多只有一个线程可以执行该段代码。底层是通过 JVM 指令 monitorenter 和 monitorexit 来完成线程对监视器锁的获取与释放。
synchronized
是 Java 中最早支持的并发控制机制,使用简单,语义清晰。在执行被 synchronized
修饰的方法或代码块时,线程必须先获得对应的监视器锁(monitor),该锁是与对象或类相关联的结构,内嵌在对象头中。JVM 在执行到 synchronized
语句时会插入 monitorenter
指令尝试获取锁,执行完毕后通过 monitorexit
指令释放锁。早期实现中,这一过程依赖操作系统底层互斥量,性能开销较大。但从 JDK 1.6 开始,Java 引入了偏向锁、轻量级锁、自旋优化等机制,极大提升了 synchronized
的执行效率,使其在无锁竞争时几乎无性能损耗。此外,synchronized
支持可重入,即同一线程可以多次获得锁而不会被阻塞,这对于嵌套方法调用尤为重要。它也天然支持异常安全,确保即使出现异常也能自动释放锁。由于其稳定性与 JVM 原生支持,synchronized
仍然是并发开发中首选的互斥工具。
说说自己是怎么使用 synchronized 关键字,在项目中用到了吗
在项目中,我使用 synchronized
来控制对共享资源的访问,防止线程间竞争。常见用法包括修饰实例方法、静态方法和代码块。尤其在多线程操作缓存、控制单例初始化等场景中,synchronized
提供了可靠的并发保障。
synchronized
的使用方式可分为三类,分别适用于不同的并发场景。第一种是修饰实例方法,即作用于当前对象的实例锁,每个对象互不影响,适合管理单对象的状态安全;第二种是修饰静态方法,此时加锁的是类的 Class 对象,所有该类实例共享同一把锁,适用于类级别的资源控制,比如单例模式;第三种是修饰代码块,可以精细指定加锁的对象,例如将不同逻辑块用不同的锁对象进行隔离,有助于降低锁竞争,提高并发性能。
在实际项目中,我曾用 synchronized
控制缓存更新的原子性,以及在多线程加载配置文件时确保只执行一次初始化逻辑。此外,还需注意避免使用字符串等常量作为锁对象,防止因常量池共享导致意外竞争。正确使用 synchronized
,既可以保证线程安全,也有助于维持代码结构的清晰与可维护性。
说一下 synchronized 底层实现原理?
synchronized
的实现依赖 JVM 层的对象监视器(monitor)。每个对象都有一把锁,当线程执行同步代码时会尝试获得锁,未获得则进入阻塞状态。JVM 利用 monitorenter 和 monitorexit 指令管理锁的获取与释放。
synchronized
在字节码层是由 monitorenter
和 monitorexit
两个指令实现的,它依赖对象的监视器(monitor)来实现线程之间的互斥访问。每个对象都关联一个 Monitor,当线程进入同步代码块时,JVM 会尝试通过 monitorenter
获取 Monitor 的控制权,若获取失败则线程会被挂起。若线程已经持有该锁,它可以重新进入(可重入),进入计数加一;当退出同步块时,执行 monitorexit
,释放一次进入计数。直到计数归零,其他线程才有机会获得锁。
从 JDK 1.6 开始,synchronized
的实现经历了多次优化:在无竞争场景下使用偏向锁,低开销地将锁"绑定"到当前线程;若出现竞争,则升级为轻量级锁,使用自旋代替阻塞;高竞争时进一步升级为重量级锁,通过操作系统互斥量实现真正的线程挂起与唤醒。可以通过 javap -c
工具查看字节码,理解其背后的执行原理。这些优化使得 synchronized 在现代 JVM 中不仅语义清晰,而且具备极高的性能竞争力。
synchronized 可重入的原理
synchronized
是一种可重入锁,意味着同一线程在持有锁的情况下可以再次获取该锁而不会发生死锁。JVM 通过在对象内部维护一个计数器和线程 ID 来实现重入,每次进入加一,退出同步块时减一,直到计数为 0 才真正释放锁。
可重入锁是并发控制中重要的特性之一,synchronized
在 Java 中默认就是可重入的。这意味着如果一个线程已经获得了某个对象的锁,在它未释放该锁之前,仍然可以继续进入由该锁保护的同步代码块或方法,而不会造成阻塞或死锁。JVM 实现这一机制的方式是在对象头中记录当前持锁线程的 thread ID 和一个重入计数器。当线程首次获取锁时,计数器设为 1,再次进入时递增;每执行完一个同步块就减 1,直到完全退出所有同步块后,计数归零,锁才真正释放。这个机制对于递归调用、链式调用等场景尤其关键,比如一个 synchronized 方法内部调用了另一个 synchronized 方法,如果不支持重入,线程将会被自己阻塞导致死锁。可重入不仅提升了锁的使用灵活性,也提升了代码结构的可读性和模块化程度。
什么是自旋?
自旋是一种替代阻塞的等待锁策略。当线程尝试获取锁失败时,不立即进入阻塞,而是通过循环检查锁状态的方式持续尝试获取锁。自旋适用于锁持有时间很短的场景,可减少线程上下文切换带来的性能开销。
传统的同步机制中,获取不到锁的线程通常会被挂起并进入阻塞状态,等待锁释放时再被唤醒。这个过程涉及从用户态切换到内核态,并伴随着上下文切换,开销较大。自旋锁(SpinLock)则提供了一种更轻量的策略:线程不放弃 CPU,而是持续在用户态进行忙循环检查锁状态。这种机制避免了频繁的线程挂起和唤醒,在锁持有时间非常短的场景下能显著提升性能。自旋通常配合 CAS 操作和轻量级锁实现,如在 ReentrantLock
或 synchronized
的轻量级锁阶段。自旋本身也有开销,如果一直获取不到锁,会导致 CPU 空转浪费资源,因此通常设定自旋次数或自旋超时时间,超过后再进入阻塞状态。合理使用自旋锁,可以在高并发、短临界区的场景下提升吞吐量,但对于长时间占用锁的操作则适得其反,可能造成 CPU 饱和。因此是否启用自旋应结合业务逻辑与运行环境综合判断。
多线程中 synchronized 锁升级的原理是什么?
synchronized
锁具备锁升级机制,从偏向锁 → 轻量级锁 → 重量级锁,逐步应对不同强度的并发场景。这种分级策略通过对象头中的标志位与线程 ID 实现,旨在降低无必要的锁竞争开销。
JDK 1.6 起,为优化 synchronized
的性能,引入了锁升级机制,以适应不同并发强度下的锁竞争情况。锁的初始状态为偏向锁,即锁对象会偏向于第一个访问它的线程,在无竞争时不做任何同步操作,仅通过判断线程 ID 识别是否重入。这使得绝大多数单线程场景下的同步操作几乎零开销。
如果锁被另一个线程尝试获取,则偏向锁失效,升级为轻量级锁。此时锁使用 CAS 操作尝试获取,若成功则无需阻塞。若 CAS 失败,则说明存在真实竞争,锁升级为重量级锁,涉及操作系统级别的线程挂起与唤醒。
锁的升级逻辑依赖于对象头中的标志位和指向持锁线程的 threadId。当线程尝试进入锁时,JVM 会判断当前锁状态并执行相应升级策略。通过这种分层机制,Java 能在保证线程安全的前提下,最大程度优化无竞争或低竞争下的执行性能。这也是现代 JVM 中 synchronized
不再"性能低下"的核心原因之一。
线程 B 怎么知道线程 A 修改了变量
线程 B 要想感知线程 A 修改了某个变量,必须借助可见性机制,如使用 volatile
修饰变量,或通过 synchronized
、Lock
等同步方式建立内存可见性。此外,也可借助 wait/notify
等通信机制进行协作通知。
Java 内存模型下,线程对共享变量的修改并不会立即被其他线程感知。这是因为每个线程都有自己的工作内存(缓存),写入变量可能暂存在本地缓存中,并未刷新到主内存。要解决这个可见性问题,最直接的方式是使用 volatile
关键字修饰变量。volatile
保证变量的修改会立即刷新到主内存,同时禁止编译器和处理器的重排序,确保其他线程能第一时间读取到最新值。
另一种常见方式是通过 synchronized
或 Lock
保护变量访问,这类机制通过获取和释放锁的过程,间接建立了 happens-before 关系,从而实现内存可见性。除此之外,wait/notify
机制也常用于线程间通信,当线程 A 修改完数据后通过 notify()
唤醒正在 wait()
的线程 B,间接完成"通知并刷新内存"的目的。这些手段的核心目的都是建立跨线程的数据传递语义,使修改对其他线程及时可见,是并发控制中的关键技术点。
当一个线程进入一个对象的 synchronized 方法 A 之后,其它线程是否可进入此对象的 synchronized 方法 B?
不能。非静态的 synchronized 方法依赖的是对象实例锁,线程 A 进入方法 A 后已经持有该对象锁,其他线程若想进入同一对象的另一个 synchronized 方法 B,必须等待锁被释放。
synchronized
的锁粒度取决于修饰对象的方法或代码块。当修饰的是实例方法时,锁定的是当前对象实例(即 this)。如果线程 A 进入了该对象的 synchronized 方法 A,它便持有了这个对象的监视器锁。此时,线程 B 若尝试调用同一对象的另一个 synchronized 方法 B,也需要获取该对象的锁。由于该锁已被线程 A 持有,线程 B 只能在同步队列中阻塞等待。这种机制确保了同一个对象的多个 synchronized 实例方法在任意时刻只能被一个线程执行,从而避免了线程间对共享资源的冲突。需要注意的是,若方法 A 和方法 B 分别属于不同的对象实例,则锁对象不同,不会互相影响;或者若 B 是 static synchronized 方法,使用的是类锁(Class 对象),那么锁也不同,不受影响。这种对象级别的同步机制是 Java 并发控制中的基本构建块之一。
synchronized、volatile、CAS 比较
synchronized
是一种基于悲观锁的同步机制,会导致线程阻塞;volatile
解决的是变量在多线程环境下的可见性与有序性问题;CAS(Compare-And-Swap)是一种无锁的乐观并发策略,适用于原子操作场景。
synchronized
、volatile
和 CAS 是 Java 并发编程中最基础的三种原语,它们各自解决不同维度的问题。synchronized
是一种排他性锁机制,通过对对象加锁来实现线程之间的互斥访问,其核心特征是互斥性、可重入性与内存可见性保障,适用于临界区较长、逻辑复杂的并发控制场景。而 volatile
本质上不是锁,它不提供原子性,但能保证变量在多线程间的可见性与禁止指令重排序。当一个线程修改了 volatile 变量,其他线程能立即看到修改后的值,是实现轻量级状态标识(如关闭标志、初始化完成标志)的理想工具。CAS 则是硬件级别的原子操作,典型应用于 AtomicInteger
等原子类中,核心思想是比较当前值是否未变,若一致则更新新值,失败则重试。CAS 机制虽然避免了加锁带来的性能损耗,但也容易陷入ABA 问题与自旋重试开销。
这三者常配合使用:如使用 volatile 标识状态变化,使用 CAS 控制原子更新,在必要时仍通过 synchronized 做逻辑保护,形成完整的并发控制体系。
特性 | synchronized | volatile | CAS |
---|---|---|---|
锁机制 | 悲观锁 | 无锁 | 乐观锁 |
原子性 | 保证 | 不保证 | 保证 |
可见性 | 保证 | 保证 | 保证 |
有序性 | 保证 | 保证 | 不保证 |
性能开销 | 较高 | 很低 | 中等 |
适用场景 | 复杂临界区 | 状态标识 | 原子操作 |
阻塞机制 | 线程阻塞 | 无阻塞 | 自旋重试 |
synchronized 和 Lock 有什么区别?
synchronized
是 Java 原生关键字,依赖 JVM 实现加锁解锁;而 Lock
是接口,通过代码控制锁的获取与释放。前者使用简单,异常自动释放锁,后者功能更强,如支持中断、定时尝试、读写锁等。
synchronized
是语言层面的内置锁,使用方式更自然,不易出错。它由 JVM 保证获取与释放的完整流程,即使在异常时也能自动释放锁。但它的灵活性较差,功能相对固定。而 Lock
是一个接口,常用的实现类如 ReentrantLock
,它基于 AQS 构建,允许开发者显式控制加锁与解锁过程。与 synchronized 相比,Lock 支持更多功能,例如:可中断锁获取、超时获取、条件队列、读写分离等,在复杂并发场景中更加灵活强大。
此外,Lock 不具备自动释放锁的机制,若 unlock 写在错误位置或漏写,会导致死锁,因此需要格外谨慎。JDK6 之后,synchronized 经过锁优化性能已大幅提升,在多数场景下与 Lock 不相上下。实际选择时,可根据业务复杂性、功能需求、代码安全性权衡使用。
特性 | synchronized | Lock |
---|---|---|
实现方式 | JVM内置关键字 | 接口实现类 |
锁释放 | 自动释放 | 手动释放 |
异常处理 | 自动释放锁 | 需try-finally |
中断响应 | 不支持 | 支持 |
定时获取 | 不支持 | 支持 |
公平锁 | 不支持 | 支持 |
条件变量 | 单一条件 | 多个条件 |
读写锁 | 不支持 | 支持 |
性能 | 优化后较好 | 较好 |
使用复杂度 | 简单 | 复杂 |
synchronized 和 Lock 如何选择?
若能使用 java.util.concurrent 工具类,应优先选用高层抽象工具;若仅需基本同步功能,synchronized
更简洁安全;若需中断控制、定时锁、读写锁等功能,则选用 Lock
更为合适。
synchronized
与 Lock
并非互相取代的关系,而是各有适用场景。对于简单的同步需求,例如保证一个方法或代码块的线程安全,synchronized
是首选,因为它使用语法简单,且异常时自动释放锁,避免死锁的风险较小。
而 Lock
的使用则更复杂,需要手动加锁与释放,推荐配合 try-finally 使用来保障安全性。不过,Lock
提供了 tryLock()
、lockInterruptibly()
等增强能力,能让线程具备中断响应能力,适用于不可控锁等待的高并发场景;同时,通过 ReadWriteLock
实现读写分离,能极大提高读多写少场景下的并发性能。
现代 Java 应用中,很多并发控制已封装在 java.util.concurrent
工具包中,如 ConcurrentHashMap
、ExecutorService
等已内部处理了锁逻辑,开发者无需亲自加锁。因此,实际项目中建议优先使用并发容器和线程池工具类,其次选择 synchronized
,最后才考虑 Lock
。这样可以降低代码复杂性,提升程序健壮性与可维护性。
synchronized 和 ReentrantLock 区别是什么?
synchronized
和 ReentrantLock
都是 Java 提供的可重入锁。前者是语言层级支持的内置锁,使用简单;后者是基于 AQS 的显式锁,功能更强大,如支持中断、定时获取、公平锁、条件变量等,更适用于复杂并发控制。
synchronized
是 Java 原生关键字,JVM 直接支持,锁的获取与释放由虚拟机自动处理。其最大优点是语法简洁,异常情况下能自动释放锁,不易出错。缺点则在于功能单一,无法响应中断,也无法控制是否公平。ReentrantLock
属于显式锁,开发者需手动加锁与释放,灵活性更强。它支持可中断的锁获取(lockInterruptibly),可设置尝试时间的 tryLock()
,可指定为公平锁,避免线程饥饿,还提供了 Condition
接口实现更复杂的等待/通知机制。
此外,它支持多个条件变量,能细化线程唤醒控制。底层实现方面,ReentrantLock
基于 AQS(AbstractQueuedSynchronizer)构建,而 synchronized
则由 JVM 通过对象监视器管理。性能方面,两者在现代 JVM 中相差不大,synchronized
已支持偏向锁、轻量级锁等优化,通常推荐在简单同步场景使用 synchronized
,复杂并发控制场景使用 ReentrantLock
。
对比维度 | synchronized | ReentrantLock |
---|---|---|
实现方式 | JVM内置关键字 | AQS实现 |
锁管理 | 自动管理 | 手动管理 |
异常处理 | 自动释放 | 需try-finally |
中断响应 | 不支持 | 支持 |
定时获取 | 不支持 | 支持 |
公平锁 | 不支持 | 支持 |
条件变量 | 单一条件 | 多个条件 |
性能 | 优化后较好 | 较好 |
使用复杂度 | 简单 | 复杂 |
适用场景 | 简单同步 | 复杂并发控制 |
volatile 关键字的作用
volatile
用于保证变量在多线程环境下的可见性和禁止指令重排序。它不会提供原子性,但能确保每次读写都直接作用于主内存,常与 CAS 操作结合使用,适用于状态标识或轻量同步场景。
在多线程中,Java 内存模型允许每个线程缓存变量副本。若没有同步措施,一个线程对变量的修改对其他线程不可见,这就需要 volatile
来解决可见性问题。被 volatile
修饰的变量每次写入都会刷新到主内存,每次读取也从主内存拉取,确保所有线程看到的是最新值。它还具有禁止指令重排序的语义,JVM 和 CPU 不得将其前后的指令重排,避免因优化带来的执行乱序。在应用中,volatile
常用于控制状态标志,如中断信号、任务完成标识,也与 CAS 机制联合用于构建高性能并发类,如 AtomicInteger
、AtomicReference
等。虽然 volatile
不能保证操作的原子性,但它的低开销和内存语义支持使其在无锁编程中广泛使用。理解其边界很关键:对复合操作仍需配合锁或原子类实现。
Java 中能创建 volatile 数组吗?
可以。Java 支持定义 volatile
修饰的数组引用,但该修饰只作用于引用本身,不能保证数组内部元素的可见性和原子性。
Java 中可以声明 volatile int[] array
,此时 volatile
修饰的是对数组对象的引用本身。当该引用被修改,比如指向另一个数组对象时,修改是具备可见性的,其他线程能立即看到最新的引用。但如果线程只是修改了数组中的某个元素,如 array[0] = 100
,则这个修改并不受 volatile
的保障。这是因为数组元素是存储在堆中的结构体,volatile
无法传播到其内部成员。
若多个线程需要并发修改数组元素,应考虑使用 AtomicIntegerArray
这类并发工具类,它基于 CAS 操作,能在元素级别提供原子性与可见性保障。因此,在需要线程安全地操作数组元素时,不能仅依赖 volatile,而应结合具体需求选择更合适的工具或机制。
操作类型 | volatile数组 | 普通数组 | AtomicIntegerArray |
---|---|---|---|
引用变更 | 可见 | 不可见 | 可见 |
元素修改 | 不可见 | 不可见 | 可见 |
原子性 | 不保证 | 不保证 | 保证 |
性能开销 | 很低 | 无 | 中等 |
适用场景 | 引用同步 | 单线程 | 并发元素操作 |
volatile 变量和 atomic 变量有什么不同?
volatile
关键字只能保证变量的可见性和禁止指令重排序,但不能保证原子性。而 AtomicInteger
等原子类是基于 CAS 实现的,可以提供真正的原子性操作,适用于并发累加、更新等场景。
在多线程环境下,volatile
和原子类常被混用,但它们的底层机制和使用目的完全不同。volatile
仅保证对变量修改的可见性,也就是说,当一个线程修改变量值时,其他线程可以立即读取到最新值。同时,它还能禁止指令重排序,避免编译器或 CPU 将关键操作乱序执行。但 volatile
并不能保证操作的原子性,典型如 count++
,它其实是三个步骤(读、加、写),在并发执行时会产生竞态条件。而 AtomicInteger
等原子类通过底层的 CAS(Compare-And-Swap)机制,确保了整个更新操作的原子性。比如 getAndIncrement()
方法就可以安全地对值进行递增,无需加锁。因此,在需要对变量进行复合操作的场景下,应使用原子类或加锁机制,而非仅靠 volatile
。总结来看,volatile
轻量、适用于状态标志,原子类则适合并发数据更新。
volatile 能使得一个非原子操作变成原子操作吗?
不能。volatile
仅能保证可见性与禁止指令重排序,无法保证复合操作的原子性。对于如 i++
这类操作,仍需依赖锁或原子类来确保线程安全。
Java 中的 volatile
关键字并不是万能的同步工具。它的作用主要是两方面:第一,确保变量的可见性,避免线程从本地缓存读取陈旧数据;第二,防止指令重排序,确保变量的修改顺序符合预期。
然而,它并不会对操作的原子性提供任何保障。所谓原子性,是指一个操作不可被中断或分解。像 i++
这种看似简单的递增,实际上包含读、改、写三个步骤,多个线程并发执行时就可能出现"丢失更新"的问题。即使使用 volatile
修饰 i
,也无法阻止多个线程读取相同旧值并写回,导致更新不一致。
为此,要么使用 synchronized 加锁包裹递增逻辑,要么使用 AtomicInteger
等类提供的原子方法来替代手动操作。注意:虽然有说法称 volatile
修饰 long
和 double
能在某些平台上保证其原子性,但在逻辑复合操作中依然不成立。
synchronized 和 volatile 的区别是什么?
synchronized
是一种互斥锁机制,既保证可见性也保证原子性;volatile
是轻量级修饰符,仅保证可见性与禁止重排序。前者适用于临界区保护,后者适用于状态同步。
synchronized
与 volatile
是 Java 并发模型中最常见的两个关键字,但它们的作用和适用场景截然不同。synchronized
是一种互斥机制,修饰代码块或方法时会在运行时对指定对象加锁,从而保证线程对共享资源的串行访问。它提供了完整的同步语义:包括互斥性(只有一个线程能访问)、可见性(锁释放后数据对其他线程可见)以及原子性(操作不可分割)。
而 volatile
是一种变量修饰符,不提供加锁能力,仅能确保变量的更新在多线程间立刻可见,且防止指令重排序。它不保证原子性,因此不能独立用于复合逻辑的并发控制。比如一个布尔型 volatile
标志非常适合用于中断通知、单例初始化标记等场景,但对计数器、累加器等场景就需要借助锁或原子类。性能上,volatile
相较 synchronized
更轻量,因为它不会导致线程阻塞或上下文切换。但随着 JVM 对 synchronized
优化(如偏向锁、轻量级锁等),它的性能也已显著提升,两者选择应以场景驱动为主。
特性 | synchronized | volatile |
---|---|---|
锁机制 | 互斥锁 | 无锁 |
原子性 | 保证 | 不保证 |
可见性 | 保证 | 保证 |
有序性 | 保证 | 保证 |
性能开销 | 较高 | 很低 |
适用场景 | 临界区保护 | 状态同步 |
阻塞机制 | 线程阻塞 | 无阻塞 |
Lock 接口和 synchronized 相比有哪些优势?
Lock
接口相比 synchronized
提供了更高的灵活性和扩展性。它支持可中断锁、公平锁、定时尝试获取锁,以及多个条件变量,能覆盖更复杂的并发场景,是一种功能更全面的同步工具。
synchronized
是 JVM 层级支持的关键字,适用于大多数基本的线程互斥场景。但 Lock
接口作为其补充,提供了更丰富的锁控制能力,尤其适用于对并发控制要求较高的系统。
首先,Lock
支持中断响应:通过 lockInterruptibly()
,线程在等待锁时可被中断,避免因不可控等待而导致线程无法回收。
其次,Lock
支持定时尝试,tryLock()
允许线程设置超时时间,在指定时间内未获取锁则自动放弃,增强了系统的弹性处理能力。
此外,它还支持公平锁策略,保证线程按照请求顺序获取锁,避免"饿死"问题。而且通过 Condition
接口,Lock
能实现比 Object.wait/notify
更细粒度的线程通信机制,支持多个条件队列。虽然使用 Lock
更灵活,但也要求手动释放锁,若未妥善处理容易造成死锁。因此,synchronized
更适合简单同步逻辑,而复杂并发需求下应选择 Lock
。
synchronized
是 JVM 层级支持的关键字,适用于大多数基本的线程互斥场景。但 Lock
接口作为其补充,提供了更丰富的锁控制能力,尤其适用于对并发控制要求较高的系统。
首先,Lock
支持中断响应:通过 lockInterruptibly()
,线程在等待锁时可被中断,避免因不可控等待而导致线程无法回收。
其次,Lock
支持定时尝试,tryLock()
允许线程设置超时时间,在指定时间内未获取锁则自动放弃,增强了系统的弹性处理能力。此外,它还支持公平锁策略,保证线程按照请求顺序获取锁,避免饿死问题。而且通过 Condition
接口,Lock
能实现比 Object.wait/notify
更细粒度的线程通信机制,支持多个条件队列。虽然使用 Lock
更灵活,但也要求手动释放锁,若未妥善处理容易造成死锁。
因此,synchronized
更适合简单同步逻辑,而复杂并发需求下应选择 Lock
。
乐观锁和悲观锁的理解及实现方式
悲观锁假设总会发生并发冲突,通常通过加锁方式避免数据竞争,如 synchronized 和数据库行锁。乐观锁假设并发概率低,通过版本号或 CAS 操作来校验更新冲突,典型如 AtomicInteger
、数据库中的条件更新语句等。
悲观锁与乐观锁的核心差异在于对并发冲突的态度。悲观锁默认认为共享资源极易被修改,因此在操作前必须加锁,以阻止其他线程访问,这种方式常用于写多读少的场景,保障数据绝对一致性。Java 中的 synchronized
、ReentrantLock
就是悲观锁的代表,获取锁失败的线程会被阻塞。
相反,乐观锁认为并发冲突较少,操作时不加锁,而是在更新时通过校验机制判断数据是否被修改。典型做法有版本号(如数据库的版本字段)、时间戳或 CAS 比较更新值。若更新失败则重新尝试,这种方式适合读多写少的系统,能大大减少线程阻塞开销。Java 中 java.util.concurrent.atomic
包提供的原子类,如 AtomicInteger
、AtomicReference
,都是通过 CAS 实现的乐观锁。它们通常与 volatile
结合使用,实现高性能的并发更新逻辑。
什么是 CAS?
CAS(Compare-And-Swap)是一种硬件级别支持的原子操作机制,用于实现无锁同步。它比较当前变量值与预期值是否一致,一致则更新为新值,否则重试,广泛用于原子类和并发控制中。
CAS 是构建高性能无锁算法的基石,尤其在多线程并发更新同一变量时,能避免加锁带来的性能瓶颈。CAS 操作涉及三个参数:内存中的当前值(V)、期望值(expected)、准备更新的新值(newValue)。操作流程是:若当前值等于期望值,则将其更新为新值;否则表示数据被其他线程修改过,当前操作失败并可选择重试。Java 中的 Unsafe
类和 AtomicInteger
就通过底层 CAS 实现线程安全的原子操作。
CAS 的最大优势在于非阻塞:线程失败不会挂起,而是进入短暂自旋尝试重试,这比传统加锁更高效。CAS 也存在一些问题,如 ABA 问题:值从 A → B 又回到 A,CAS 无法识别变化。为解决此问题,JDK 提供了 AtomicStampedReference
,通过版本戳配合判断。
另外,频繁失败的自旋可能造成性能损耗,因此在高竞争环境下仍需谨慎评估使用场景。CAS 是现代并发编程中的关键工具,理解其机制有助于深入掌握无锁设计原理。
CAS 会产生什么问题?
CAS(Compare-And-Swap)虽然避免了传统加锁的性能瓶颈,但并非没有副作用。它常见的问题包括 ABA 问题、自旋开销过大、以及对多个变量操作时无法保证原子性。
CAS 机制的核心思想是比较内存中当前值是否等于预期值,若相同则更新为新值。这种乐观并发策略能显著提高性能,但在某些场景下会引发副作用。
首先是 ABA 问题:线程 A 读取到变量值为 A,线程 B 在其间将 A 改为 B,又改回 A,此时线程 A 再次尝试 CAS,因值未变而误以为变量未被修改,导致潜在错误。为解决此问题,可使用带版本戳的 AtomicStampedReference
,通过附加信息识别是否发生过变化。其次,自旋消耗也是 CAS 的弱点之一。在并发竞争激烈的场景下,线程反复尝试 CAS 失败会导致大量 CPU 时间浪费,反而可能不如加锁来得高效。最后,CAS 是针对单个变量的原子操作,对于多个变量组成的逻辑操作,CAS 并不能保证整体的原子性,此时仍需引入锁机制。因此,在实际使用 CAS 时,需要结合具体业务、数据结构与并发程度合理评估其适用性。
什么是原子类?
原子类是 Java 并发包中提供的一组基于 CAS 实现的线程安全工具,能在不加锁的前提下完成变量的原子更新。它们通常位于 java.util.concurrent.atomic
包中,如 AtomicInteger
、AtomicBoolean
等。
在并发编程中,很多场景只需要对某个变量进行原子更新操作,并不需要完整的同步块。为此,Java 提供了原子类来简化无锁编程,这些类底层均基于 CAS 原语实现。以 AtomicInteger
为例,它通过 CAS 操作实现了线程安全的递增、递减、比较并设置等原子操作。原子类不仅保证了操作的原子性,还避免了加锁带来的性能损耗,因此在需要对变量进行复合操作的场景下,应优先考虑使用原子类。
