Java多线程学习——原理篇

Java内存模型

Java内存模型(JMM)是一组规范,定义了多线程程序如何与内存进行交互。JMM提供了共享变量的可见性、原子性和有序性的保证,是理解Java并发编程的关键。以下将具体介绍Java内存模型:

  1. JMM的基本概念
    • JMM解决了两个核心问题:可见性和有序性。
    • CPU缓存和系统内核通过缓存一致性协议和内存屏障来实现这些特性。
  2. CPU缓存与可见性
    • CPU会在处理器核心中使用缓存存储数据,每个核心都有自己的缓存,如L1、L2和L3缓存。
    • 不同线程运行在不同的处理器核心上时,对共享变量的修改可能不会立即对其他线程可见。
    • 缓存一致性协议(如MESI协议)用于确保多个处理器核心的缓存数据一致。
  3. 内存屏障与有序性
    • 内存屏障防止处理器对特定操作进行重排序,从而保证指令执行的顺序。
    • 主要分为加载屏障、存储屏障和全屏障。
    • volatile关键字在Java中使用内存屏障来确保对变量的读写操作不会被重排序,并且修改立即对其他线程可见。
  4. JMM的核心概念
    • 主内存与工作内存:主内存是所有线程共享的,而每个线程都有自己的工作内存,保存了主内存中变量的副本。
    • 内存可见性:JMM规定了线程对变量的读取和写入顺序,确保变量的修改在其他线程中可见。
    • JMM的三大特性:原子性、可见性、有序性。
  5. JMM中的操作
    • JMM定义了八种操作来完成主内存和工作内存之间的交互,包括lock、unlock、read、load、use、assign、store和write。
    • 这些操作确保了内存操作的原子性和可见性。
  6. Happens-before规则
    • 是判断数据是否存在竞争、线程是否安全的依据。
    • 包括程序次序规则、监视器锁规则、volatile变量规则、传递性规则、start()规则、join()规则和中断规则。

重排序与happends-before

在Java内存模型(JMM)中,重排序(Reordering)和Happens-before原则是两个核心概念,它们对于理解并发编程中的内存一致性和可见性至关重要。下面将详细解释这两个概念及其之间的关系:

1. 重排序

定义与目的:
  • 重排序是指编译器、运行时或处理器为了优化指令的执行顺序,而对代码中的指令进行重新排序的过程。
  • 重排序的主要目的是提高代码的执行效率,例如通过更好地利用CPU资源和减少阻塞时间。
类型:
  • 编译器重排序:编译器在编译时可能会对源代码中的指令进行重新排序。
  • 处理器重排序:处理器在执行时可能会改变指令的执行顺序。
  • 内存系统重排序:由于内存系统的优化,对内存的操作可能会被重新排序。
有序性保证:
  • 虽然重排序可以提升性能,但它可能会引入线程安全问题。为了防止这种情况,JMM提供了内存屏障等机制来限制重排序。

2. Happens-before原则

定义:
  • Happens-before原则是JMM中用于定义操作之间顺序的一项规则,它保证了操作的可见性、有序性和原子性。
规则:
  • 程序次序规则:在同一个线程中,前面的操作Happens-before后面的操作。
  • 监视器锁规则:解锁操作Happens-before于同一个监视器锁的后续锁定操作。
  • volatile变量规则:volatile变量的写操作Happens-before于其后续的读操作。
  • 传递性:如果A Happens-before B,且B Happens-before C,那么A Happens-before C。
  • start()和join()规则:线程的start()操作Happens-before该线程中的所有操作,而线程的结束操作Happens-before于join()返回操作。
作用:
  • Happens-before原则为Java程序员提供了一种判断内存操作是否可见、是否有序的简单方法,无需深入了解底层的内存模型细节。

3. 重排序与Happens-before的关系

  • 限制重排序:Happens-before原则可以用来限制重排序,保证程序的正确性。例如,通过使用volatile关键字,可以防止特定变量的读写操作被重排序。
  • 保证可见性:Happens-before原则保证了早于某个操作的所有操作对该操作都是可见的,这对避免重排序带来的问题至关重要。

volatitle

volatile是Java中的一种关键字,主要用于保证变量在多线程环境下的可见性和禁止指令重排,但并不保证原子性

volatile作为Java并发编程中的一个重要组成部分,通过保证共享变量的可见性和禁止指令重排,为多线程程序提供稳定性和可靠性。同时,为了充分利用volatile的特性,开发人员需理解其在优化禁用、内存可见性和有序性保证等方面的应用,并结合实际场景合理选择同步机制,以确保线程安全和数据一致性。

在Java的LSP(单例模式)中,volatile关键字起到非常重要的作用,尤其是在“双重锁检查”(Double-Checked Locking)模式中。下面将详细探讨volatile在单例模式中的作用:

  1. 可见性保证
    • 共享变量可见性:当一个线程修改了volatile变量的值,这个修改对其他所有线程立即可见[2]。
    • 硬件级支持:volatile变量的读写通过内存屏障实现,确保不同线程间变量值的同步[4]。
  2. 指令重排禁止
    • 编译器重排禁止:使用volatile修饰共享变量时,编译器在编译期间会在指令序列中插入内存屏障,防止特定类型的处理器重排序[1]。
    • 处理器重排禁止:volatile写操作和后续的volatile读操作之间不能进行重排序[2]。
  3. happens-before
    • 有序性规则:JMM定义了一系列happens-before规则,包括监视器锁规则、volatile变量规则等[3]。
    • 传递性规则:如果A happens-before B,且B happens-before C,那么A happens-before C[3]。
  4. 双重锁检查安全
    • 减少同步开销:第一次检查instance是否为null,可以避免每次都进入同步块,从而减少同步开销[3]。
    • 防止指令重排:通过volatile保证instance的初始化过程不会被重排序,确保对象完全创建后再被其他线程使用[5]。
  5. DCL注意事项
    • 变量声明volatile:必须将instance声明为volatile类型,以禁止重排序[5]。
    • 同步块内部检查:第二次检查是必要的,因为多个线程可能同时通过第一次检查[3]。
  6. 性能考量
    • 减少开销:通过两次检查,只有第一次实例化时需要同步,之后的调用无需同步,从而减少了开销[3]。
    • 适用场景:适用于并发高、实例化开销大的场景[5]。
  7. 替代方案
    • 静态内部类:利用类的初始化过程来保证实例的唯一和懒加载[5]。
    • 枚举实现:通过枚举类型来保证实例的唯一性和线程安全[5]。

总之,通过上述分析可以看出,在单例模式的实现中,volatile关键字起到了至关重要的作用,它不仅保证了共享变量的可见性,还通过禁止指令重排,确保了对象在多线程环境中的安全初始化和使用。

synchronized与锁

synchronized

synchronized关键字在Java中用于实现线程同步,它可以确保在同一时刻只有一个线程能够访问被锁定的代码块或方法。这有助于防止多线程环境下的数据不一致和竞态条件。

synchronized关键字可以应用于以下三种场景:

  1. 实例方法:当synchronized关键字放在实例方法上时,锁对象是当前实例对象(this)。这意味着同一时间只有一个线程可以执行该实例的所有synchronized实例方法。
public synchronized void instanceLock() {
    // code
}
  1. 静态方法:当synchronized关键字放在静态方法上时,锁对象是当前类的Class对象。这意味着同一时间只有一个线程可以执行该类的所有synchronized静态方法。
public static synchronized void classLock() {
    // code
}
  1. 代码块:当synchronized关键字放在代码块上时,锁对象可以是任何对象。括号内的对象就是锁对象。这意味着同一时间只有一个线程可以执行该代码块。
public void blockLock() {
    Object o = new Object();
    synchronized (o) {
        // code
    }
}

需要注意的是,synchronized关键字不能保证原子性,只能保证可见性和互斥性。在某些情况下,可能需要使用其他并发工具,如java.util.concurrent包中的Lock接口和相关实现类,以实现更复杂的同步需求。

Java中的锁有以下几种:

  1. 无锁状态:没有对资源进行锁定,任何线程都可以尝试去修改它。
  2. 偏向锁状态:当一个线程访问同步块时,会在对象头中存储偏向的线程ID。当其他线程尝试访问时,会检查对象头中的线程ID是否与当前线程相同,如果相同则可以直接进入同步块,否则需要撤销偏向锁并升级为轻量级锁。
  3. 轻量级锁状态:当多个线程交替访问同步块时,会在线程栈中创建Lock Record,并将对象头中的Mark Word复制到Lock Record中。当线程再次访问同步块时,会先尝试在栈中找到对应的Lock Record,如果找到了则可以直接进入同步块,否则需要升级为重量级锁。
  4. 重量级锁状态:当多个线程同时竞争同一个锁资源时,会在堆中创建monitor对象,并将对象头中的Mark Word指向monitor对象。此时线程需要获取monitor对象的控制权才能进入同步块。

这几种锁会随着竞争情况逐渐升级,锁的升级很容易发生,但是锁降级发生的条件会比较苛刻,锁降级发生在Stop The World期间,当JVM进入安全点的时候,会检查是否有闲置的锁,然后进行降级。

Java对象头

Java对象头主要包括两部分:Mark Word和Class Metadata Address。Mark Word用于存储对象的hashCode、锁信息等,而Class Metadata Address则存储了指向对象类型数据的指针。

Mark Word的格式如下:

  • 锁状态:29位(32位系统)或61位(64位系统),用于表示对象的锁状态,如无锁、偏向锁、轻量级锁、重量级锁等。
  • 是否是偏向锁:1位,用于标识对象是否为偏向锁。
  • 锁标志位:2位,用于表示锁的状态,如00表示无锁,01表示偏向锁,10表示轻量级锁,11表示重量级锁。

当对象状态为偏向锁时,Mark Word存储的是偏向的线程ID;当状态为轻量级锁时,Mark Word存储的是指向线程栈中Lock Record的指针;当状态为重量级锁时,Mark Word为指向堆中的monitor对象的指针。

偏向锁

偏向锁是一种优化锁策略,它的主要目的是减少无竞争情况下的同步开销。当一个线程第一次获取到锁时,它会将锁的偏向设置为自己,并将锁的状态设置为偏向锁。当该线程再次请求同一把锁时,如果锁的偏向仍然指向自己,那么该线程可以直接进入临界区,无需进行额外的同步操作。

偏向锁的实现原理如下:

  1. 当一个线程第一次进入同步块时,它会检查锁对象的Mark Word,如果锁对象没有被锁定(即锁标志位为01),则将锁对象的偏向线程ID设置为当前线程ID,并将锁标志位设置为01(表示偏向锁)。

  2. 当该线程再次进入同步块时,它会检查锁对象的Mark Word,如果锁对象的偏向线程ID仍然指向自己,那么该线程可以直接进入临界区,无需进行额外的同步操作。

  3. 如果锁对象的偏向线程ID不指向自己,说明有其他线程正在竞争这把锁。此时,该线程会尝试使用CAS操作来替换锁对象的Mark Word中的偏向线程ID为当前线程ID。如果替换成功,说明之前的线程已经不存在了,锁仍然保持偏向状态;如果替换失败,说明之前的线程仍然存在,那么需要暂停之前的线程,将锁标志位设置为00(表示轻量级锁),并升级锁状态。

撤销偏向锁的过程相对复杂,主要包括以下几个步骤:

  1. 在一个安全点停止拥有锁的线程。

  2. 遍历线程栈,修复锁记录和Mark Word,使其变成无锁状态。

  3. 唤醒被停止的线程,将当前锁升级成轻量级锁。

需要注意的是,偏向锁在竞争激烈的场景下可能会导致性能下降,因此可以通过设置-XX:UseBiasedLocking=false来关闭偏向锁功能。

轻量锁

轻量级锁是Java 6引入的一种锁优化机制,它介于偏向锁和重量级锁之间。当多个线程在不同时段获取同一把锁,即不存在锁竞争的情况下,轻量级锁可以有效地减少线程阻塞和唤醒的开销。

轻量级锁的加锁过程:

  1. 创建锁记录:当一个线程尝试获取一把锁时,如果发现该锁是轻量级锁,它会在当前线程的栈帧中创建一个锁记录(Displaced Mark Word)的空间。

  2. 复制Mark Word:线程将锁对象的Mark Word复制到自己的Displaced Mark Word中。

  3. 尝试CAS操作:线程使用CAS操作尝试将锁对象的Mark Word替换为指向自己栈中锁记录的指针。如果CAS操作成功,表示当前线程获得了锁;如果失败,表示有其他线程竞争这把锁。

  4. 自旋:如果CAS操作失败,线程不会立即进入阻塞状态,而是采用自旋的方式不断尝试获取锁,以期望很快就能获得锁。

  5. 适应性自旋:自旋的次数不是固定的,而是根据前一次自旋的结果来调整。如果线程在自旋过程中成功获得了锁,那么下次自旋的次数可能会增加;如果自旋失败,次数可能会减少。

  6. 升级为重量级锁:如果线程自旋一定次数后仍然无法获得锁,那么锁会升级为重量级锁,同时当前线程会进入阻塞状态。

轻量级锁的释放过程:

  1. CAS操作:释放锁时,当前线程会使用CAS操作将Displaced Mark Word的内容复制回锁对象的Mark Word中。

  2. 复制成功:如果没有其他线程竞争锁(即没有锁升级为重量级锁的情况),那么这个复制操作会成功,锁被成功释放。

  3. 复制失败:如果有其他线程因为自旋失败导致锁升级为重量级锁,那么CAS操作会失败。此时,当前线程会释放锁并唤醒被阻塞的线程。

轻量级锁通过减少线程阻塞和唤醒的开销,提高了程序在无锁竞争情况下的性能。然而,在锁竞争激烈的场景下,轻量级锁可能会导致过多的自旋,从而消耗大量的CPU资源。因此,根据实际应用场景选择合适的锁策略是非常重要的。

重量锁

重量级锁是Java中最基本的一种锁,它依赖于操作系统的互斥量(mutex)实现。当多个线程同时请求某个对象锁时,重量级锁会将请求锁的线程放入一个竞争队列(Contention List),然后选择一个有资格成为候选人的线程进入Entry List。

在重量级锁状态下,线程之间的状态转换需要相对较长的时间,因此效率较低。但是,被阻塞的线程不会消耗CPU资源。

重量级锁是非公平的,也就是说,线程获得锁的顺序并不是按照它们请求锁的顺序决定的。线程首先尝试自旋获取锁,如果自旋失败,则进入等待队列。对于已经在等待队列中的线程来说,这可能显得不公平,因为自旋线程可能会抢占Ready线程的锁。

当线程释放锁时,它会从Contention List或Entry List中挑选一个线程唤醒。被选中的线程称为假定继承人(Heir presumptive),但并不意味着它一定能获得锁。这是因为synchronized是非公平的,所以假定继承人不一定能获得锁。

需要注意的是,当调用一个锁对象的wait或notify方法时,如果当前锁的状态是偏向锁或轻量级锁,则会先膨胀成重量级锁。这样做是为了确保线程在等待和唤醒过程中能够正确地处理锁状态的变化。

CAS与原子操作

CAS(Compare And Swap)是一种原子操作,用于在多线程环境下实现无锁数据结构或算法。CAS操作包含三个参数:V(要更新的变量),E(预期值),N(新值)。CAS操作的基本逻辑如下:

  1. 检查变量V的值是否等于预期值E。
  2. 如果等于,将V的值更新为新值N,并返回true表示操作成功。
  3. 如果不等于,不做任何修改,并返回false表示操作失败。

CAS操作的原子性保证了在多线程环境下,只有一个线程能够成功更新变量V的值。其他线程的CAS操作将会失败,因为V的值已经不再等于它们的预期值E。

在Java中,CAS操作主要通过Unsafe类中的native方法实现。Unsafe类提供了多个CAS方法,如compareAndSwapObject、compareAndSwapInt和compareAndSwapLong等,这些方法在不同的数据类型上执行CAS操作。

Unsafe类中的CAS方法是用C++实现的,具体的实现细节依赖于操作系统和CPU架构。例如,在Linux系统的X86架构下,CAS操作主要通过cmpxchgl指令实现,而在多处理器情况下可能需要使用lock指令来保证操作的原子性。

除了CAS操作,Unsafe类还提供了其他一些功能,如线程的挂起和恢复(通过park和unpark方法实现,LockSupport类底层就是调用这两个方法),以及支持反射操作的allocateInstance方法等。

CAS(Compare And Swap)操作虽然能够提供原子性保证,但在实现原子操作时也面临一些挑战和问题。以下是CAS实现原子操作的三大问题及其解决方案:

10.5.1 ABA问题

问题描述:

ABA问题是指一个值从一个状态A变为状态B,然后又变回状态A。在这种情况下,使用CAS操作无法检测到值的变化,但实际上值已经被修改过。

解决方案:

为了解决ABA问题,可以在变量前面追加一个版本号或时间戳。从JDK 1.5开始,JDK的atomic包中提供了一个AtomicStampedReference类来解决这个问题。这个类的compareAndSet方法不仅检查当前引用是否等于预期引用,还检查当前标志(版本号或时间戳)是否等于预期标志。只有当这两个条件都满足时,才使用CAS操作更新值为新引用和新标志。

public boolean compareAndSet(V expectedReference,
                            V newReference,
                            int expectedStamp,
                            int newStamp) {
    Pair<V> current = pair;
    return expectedReference == current.reference &&
           expectedStamp == current.stamp &&
           ((newReference == current.reference &&
             newStamp == current.stamp) ||
            casPair(current, Pair.of(newReference, newStamp)));
}

10.5.2 循环时间长开销大

问题描述:

CAS操作通常与自旋结合使用。如果自旋CAS长时间不成功,将会占用大量的CPU资源。

解决方案:

让JVM支持处理器提供的pause指令。pause指令能够在自旋失败时让CPU睡眠一小段时间再继续自旋,从而降低读操作的频率,减少因内存顺序冲突而导致的CPU流水线重排的代价。

10.5.3 只能保证一个共享变量的原子操作

问题描述:

CAS操作只能保证单个共享变量的原子性。

解决方案:

有两种解决方案:

  1. 使用从JDK 1.5开始提供的AtomicReference类来保证对象之间的原子性。可以将多个变量封装到一个对象中,然后对这个对象进行CAS操作。

  2. 使用锁。在锁的临界区内,可以确保只有当前线程能够操作共享变量,从而保证原子性。

通过这些解决方案,可以有效地解决CAS在实现原子操作时可能遇到的问题,提高多线程程序的正确性和效率。

  • 22
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

吴代庄

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值