Java并发理论
Java并发理论
39. 线程之间如何通信及线程之间如何同步
回答:
在 Java 并发编程中,线程之间的通信通常依赖共享内存模型,通过对共享变量的读写完成信息传递。通信是隐式发生的,而同步则是显式控制的过程,用于协调线程执行顺序和数据可见性,常见方式包括 synchronized、Lock 和 volatile 等。
分析:
在共享内存模型下,线程之间并不是通过消息显式传递来交换数据,而是通过共享变量间接通信。Java 中线程通信的"隐式"特性,意味着变量值的变化需要借助同步手段才能被其他线程感知,否则就可能因为 CPU 缓存、编译器优化等原因导致"看不见"最新的数据。这正是内存可见性问题的根源。Java 提供的同步机制如 synchronized、Lock、volatile 等,不仅用来互斥访问共享资源,更重要的是用于建立happens-before 关系,从而保证写入操作对其他线程可见。例如,当一个线程释放锁后,另一个线程获取同一把锁,那么前者对共享变量的写入对后者是可见的。再如使用 volatile 修饰变量时,写操作立即刷新到主内存,读操作也会直接从主内存读取,从而避免了线程本地缓存不一致的问题。理解通信与同步的配合关系,是编写高质量并发程序的基础,既能保障正确性,也有助于提升系统性能。
40. 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 的语义保障。
41. Java 怎么进行并发控制?
回答:
Java 提供两大类并发控制手段:悲观锁和乐观锁。悲观锁如 synchronized 和 ReentrantLock,通过互斥锁控制访问;乐观锁如 CAS 原语及原子类,利用无锁机制提高并发效率。
分析:
Java 在并发控制上提供了丰富的技术选择,满足不同粒度与性能要求。悲观锁代表如 synchronized、ReentrantLock 等,它们在设计上假定冲突是常态,因此通过加锁机制来避免并发访问问题。synchronized 是最基本的内置锁,底层由对象的 monitor 监视器实现,配合 JVM 字节码指令 monitorenter 和 monitorexit 实现线程互斥。其使用方式包括修饰实例方法、静态方法或同步代码块。JDK 1.6 以后还引入偏向锁、轻量级锁等机制对其做了性能优化。而 ReentrantLock 是 java.util.concurrent 包中的显式锁,基于 AQS(AbstractQueuedSynchronizer)构建,支持更多功能如可中断获取、公平锁、条件变量等,是复杂并发控制的首选工具。与悲观锁不同,乐观锁假设冲突较少,因此采用 CAS(比较并交换)机制,如 AtomicInteger 等类来保证原子性操作,避免了线程阻塞。在高并发场景下,乐观锁由于其非阻塞特性往往能获得更好性能,但不适合对一致性要求极高的复合逻辑。因此,悲观与乐观锁应结合具体场景灵活选用。
42. synchronized 关键字
回答:synchronized 是 Java 中用于多线程并发控制的关键字,能够对方法或代码块加锁,确保同一时刻最多只有一个线程可以执行该段代码。底层是通过 JVM 指令 monitorenter 和 monitorexit 来完成线程对监视器锁的获取与释放。
分析:synchronized 是 Java 中最早支持的并发控制机制,使用简单,语义清晰。在执行被 synchronized 修饰的方法或代码块时,线程必须先获得对应的监视器锁(monitor),该锁是与对象或类相关联的结构,内嵌在对象头中。JVM 在执行到 synchronized 语句时会插入 monitorenter 指令尝试获取锁,执行完毕后通过 monitorexit 指令释放锁。早期实现中,这一过程依赖操作系统底层互斥量,性能开销较大。但从 JDK 1.6 开始,Java 引入了偏向锁、轻量级锁、自旋优化等机制,极大提升了 synchronized 的执行效率,使其在无锁竞争时几乎无性能损耗。此外,synchronized 支持可重入,即同一线程可以多次获得锁而不会被阻塞,这对于嵌套方法调用尤为重要。它也天然支持异常安全,确保即使出现异常也能自动释放锁。由于其稳定性与 JVM 原生支持,synchronized 仍然是并发开发中首选的互斥工具。
43. 说说自己是怎么使用 synchronized 关键字,在项目中用到了吗
回答:
在项目中,我使用 synchronized 来控制对共享资源的访问,防止线程间竞争。常见用法包括修饰实例方法、静态方法和代码块。尤其在多线程操作缓存、控制单例初始化等场景中,synchronized 提供了可靠的并发保障。
分析:synchronized 的使用方式可分为三类,分别适用于不同的并发场景。第一种是修饰实例方法,即作用于当前对象的实例锁,每个对象互不影响,适合管理单对象的状态安全;第二种是修饰静态方法,此时加锁的是类的 Class 对象,所有该类实例共享同一把锁,适用于类级别的资源控制,比如单例模式;第三种是修饰代码块,可以精细指定加锁的对象,例如将不同逻辑块用不同的锁对象进行隔离,有助于降低锁竞争,提高并发性能。在实际项目中,我曾用 synchronized 控制缓存更新的原子性,以及在多线程加载配置文件时确保只执行一次初始化逻辑。此外,还需注意避免使用字符串等常量作为锁对象,防止因常量池共享导致意外竞争。正确使用 synchronized,既可以保证线程安全,也有助于维持代码结构的清晰与可维护性。
44. 说一下 synchronized 底层实现原理?
回答:synchronized 的实现依赖 JVM 层的对象监视器(monitor)。每个对象都有一把锁,当线程执行同步代码时会尝试获得锁,未获得则进入阻塞状态。JVM 利用 monitorenter 和 monitorexit 指令管理锁的获取与释放。
分析:synchronized 在字节码层是由 monitorenter 和 monitorexit 两个指令实现的,它依赖对象的监视器(monitor)来实现线程之间的互斥访问。每个对象都关联一个 Monitor,当线程进入同步代码块时,JVM 会尝试通过 monitorenter 获取 Monitor 的控制权,若获取失败则线程会被挂起。若线程已经持有该锁,它可以重新进入(可重入),进入计数加一;当退出同步块时,执行 monitorexit,释放一次进入计数。直到计数归零,其他线程才有机会获得锁。从 JDK 1.6 开始,synchronized 的实现经历了多次优化:在无竞争场景下使用偏向锁,低开销地将锁"绑定"到当前线程;若出现竞争,则升级为轻量级锁,使用自旋代替阻塞;高竞争时进一步升级为重量级锁,通过操作系统互斥量实现真正的线程挂起与唤醒。可以通过 javap -c 工具查看字节码,理解其背后的执行原理。这些优化使得 synchronized 在现代 JVM 中不仅语义清晰,而且具备极高的性能竞争力。
45. synchronized 可重入的原理
回答:synchronized 是一种可重入锁,意味着同一线程在持有锁的情况下可以再次获取该锁而不会发生死锁。JVM 通过在对象内部维护一个计数器和线程 ID 来实现重入,每次进入加一,退出同步块时减一,直到计数为 0 才真正释放锁。
分析:
可重入锁是并发控制中重要的特性之一,synchronized 在 Java 中默认就是可重入的。这意味着如果一个线程已经获得了某个对象的锁,在它未释放该锁之前,仍然可以继续进入由该锁保护的同步代码块或方法,而不会造成阻塞或死锁。JVM 实现这一机制的方式是在对象头中记录当前持锁线程的 thread ID 和一个重入计数器。当线程首次获取锁时,计数器设为 1,再次进入时递增;每执行完一个同步块就减 1,直到完全退出所有同步块后,计数归零,锁才真正释放。这个机制对于递归调用、链式调用等场景尤其关键,比如一个 synchronized 方法内部调用了另一个 synchronized 方法,如果不支持重入,线程将会被自己阻塞导致死锁。可重入不仅提升了锁的使用灵活性,也提升了代码结构的可读性和模块化程度。
46. 什么是自旋
回答:
自旋是一种替代阻塞的等待锁策略。当线程尝试获取锁失败时,不立即进入阻塞,而是通过循环检查锁状态的方式持续尝试获取锁。自旋适用于锁持有时间很短的场景,可减少线程上下文切换带来的性能开销。
分析:
传统的同步机制中,获取不到锁的线程通常会被挂起并进入阻塞状态,等待锁释放时再被唤醒。这个过程涉及从用户态切换到内核态,并伴随着上下文切换,开销较大。自旋锁(SpinLock)则提供了一种更轻量的策略:线程不放弃 CPU,而是持续在用户态进行忙循环检查锁状态。这种机制避免了频繁的线程挂起和唤醒,在锁持有时间非常短的场景下能显著提升性能。自旋通常配合 CAS 操作和轻量级锁实现,如在 ReentrantLock 或 synchronized 的轻量级锁阶段。自旋本身也有开销,如果一直获取不到锁,会导致 CPU 空转浪费资源,因此通常设定自旋次数或自旋超时时间,超过后再进入阻塞状态。合理使用自旋锁,可以在高并发、短临界区的场景下提升吞吐量,但对于长时间占用锁的操作则适得其反,可能造成 CPU 饱和。因此是否启用自旋应结合业务逻辑与运行环境综合判断。
47. 多线程中 synchronized 锁升级的原理是什么?
回答:synchronized 锁具备锁升级机制,从偏向锁 → 轻量级锁 → 重量级锁,逐步应对不同强度的并发场景。这种分级策略通过对象头中的标志位与线程 ID 实现,旨在降低无必要的锁竞争开销。
分析:
JDK 1.6 起,为优化 synchronized 的性能,引入了"锁升级"机制,以适应不同并发强度下的锁竞争情况。锁的初始状态为偏向锁,即锁对象会"偏向"于第一个访问它的线程,在无竞争时不做任何同步操作,仅通过判断线程 ID 识别是否重入。这使得绝大多数单线程场景下的同步操作几乎零开销。如果锁被另一个线程尝试获取,则偏向锁失效,升级为轻量级锁。此时锁使用 CAS 操作尝试获取,若成功则无需阻塞。若 CAS 失败,则说明存在真实竞争,锁升级为重量级锁,涉及操作系统级别的线程挂起与唤醒。锁的升级逻辑依赖于对象头中的标志位和指向持锁线程的 threadId。当线程尝试进入锁时,JVM 会判断当前锁状态并执行相应升级策略。通过这种分层机制,Java 能在保证线程安全的前提下,最大程度优化无竞争或低竞争下的执行性能。这也是现代 JVM 中 synchronized 不再"性能低下"的核心原因之一。
48. 线程 B 怎么知道线程 A 修改了变量
回答:
线程 B 要想感知线程 A 修改了某个变量,必须借助可见性机制,如使用 volatile 修饰变量,或通过 synchronized、Lock 等同步方式建立内存可见性。此外,也可借助 wait/notify 等通信机制进行协作通知。
分析:
Java 内存模型下,线程对共享变量的修改并不会立即被其他线程感知。这是因为每个线程都有自己的工作内存(缓存),写入变量可能暂存在本地缓存中,并未刷新到主内存。要解决这个可见性问题,最直接的方式是使用 volatile 关键字修饰变量。volatile 保证变量的修改会立即刷新到主内存,同时禁止编译器和处理器的重排序,确保其他线程能第一时间读取到最新值。另一种常见方式是通过 synchronized 或 Lock 保护变量访问,这类机制通过获取和释放锁的过程,间接建立了 happens-before 关系,从而实现内存可见性。除此之外,wait/notify 机制也常用于线程间通信,当线程 A 修改完数据后通过 notify() 唤醒正在 wait() 的线程 B,间接完成"通知并刷新内存"的目的。这些手段的核心目的都是建立跨线程的数据传递语义,使修改对其他线程及时可见,是并发控制中的关键技术点。
49. 当一个线程进入一个对象的 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 并发控制中的基本构建块之一。