Java并发编程3(CAS)
Java内存模型(JMM)
概念
百度百科:java内存模型_百度百科
Java内存模型(Java Memory Model, JMM)是Java语言规范的一部分,它定义了程序中各个线程如何通过内存进行通信的规则。这个模型确保了在多线程环境下,不同线程对共享变量的操作具有一定的可见性和有序性,从而避免了一些潜在的问题,如竞态条件和内存一致性错误。
JMM特性
- 可见性:指的是一个线程对共享变量所做的修改需要对其他线程变得可见。如果没有适当的同步机制,一个线程所做的更改可能不会立即或根本不会对其他线程显现出来。JMM通过使用volatile关键字、synchronized块或方法等机制来确保可见性。
- 有序性:Java允许编译器和处理器对指令进行重排序以优化性能,只要这种重排序不改变程序在单线程环境下的行为。然而,在多线程环境中,这可能导致意外的行为。JMM通过规定happens-before原则和其他同步手段来防止有害的重排序,确保程序的执行顺序符合开发者的预期。
- 原子性:指的是操作不可分割,要么全部执行,要么完全不执行。对于基本数据类型的读取和写入(除long和double外),Java保证它们是原子性的。但对于复合操作(如i++),则不是原子性的,可能会导致竞态条件。为了实现原子性,可以使用synchronized、显式锁或者java.util.concurrent.atomic包中的类。
可见性
1 |
|
原因
- 处理器缓存(Cache):为了减少访问主内存的延迟,每个处理器核心通常有自己的缓存(L1, L2, 甚至L3)。当一个线程在一个核心上修改了共享变量,这个更新首先发生在该核心的缓存中,并不会立刻写回到主内存。因此,运行在其他核心上的线程可能暂时看不到这个更新,因为它们可能会从自己的缓存中读取旧值。
- 乱序执行(Out-of-order Execution):为了优化性能,现代处理器可能会改变指令的实际执行顺序,只要这种重排序不影响单个线程内的程序结果。然而,在多线程环境中,这样的重排序可能导致一个线程所见的执行顺序不同于另一个线程,从而影响共享数据的一致性。
- 编译器优化:编译器在编译程序时也会进行各种优化,包括指令重排、消除看似冗余的读写操作等。虽然这些优化在单线程环境下是安全的,但在多线程环境下可能会导致一个线程看不到另一个线程对共享变量所做的最新修改。
- 存储一致性模型(Memory Consistency Models):不同的硬件架构和编程语言实现有着不同的存储一致性模型。例如,x86架构提供相对较强的一致性保证,而ARM和PowerPC则允许更宽松的内存模型,这可能导致不同线程间观察到的程序行为有所不同。
解决方式
- 使用同步机制:如互斥锁(mutex)、信号量(semaphore)等来保护共享资源,确保同一时间只有一个线程能够访问该资源。
- 使用volatile关键字:在Java等语言中,声明一个变量为volatile可以保证对该变量的读写操作不会被编译器或处理器重排序,同时每次读取都会从主内存获取最新值,而不是使用本地缓存中的值。
- 使用原子变量:原子变量提供了不依赖于锁机制的线程安全操作,适用于某些特定场景下的高效并发控制。
- 使用高级同步结构:例如读写锁、条件变量、栅栏(barrier)等,可以根据具体情况提供更细粒度或更适合场景的同步方案。
- 使用内存屏障(Memory Barrier)/ 内存栅栏(Memory Fence):这是一种硬件级别的机制,可以防止编译器和CPU对指令进行跨屏障的重排序,确保某些操作的顺序性。
有序性
指令重排序引起的问题:
为了提高执行效率,编译器和处理器可能对指令顺序进行调整,只要这种重排序不影响单个线程内的程序结果。但在多线程环境中,如果一个线程依赖另一个线程的操作结果,这种重排序可能导致未预期的行为。
解决方案
- 使用synchronized关键字:synchronized块或方法不仅能保证原子性,还能保证有序性和可见性。进入synchronized块时会插入内存屏障,确保所有之前的操作都已完成且对其他线程可见;退出时也会插入内存屏障,确保所有后续操作都不会被提前执行。
- 使用volatile关键字:声明一个变量为volatile可以防止对其读写操作被重排序,并确保每次读取都会从主内存获取最新值,而不是从本地缓存中。这对于状态标志等简单的同步需求非常有用。
- 使用java.util.concurrent包下的高级并发工具:如ReentrantLock, CountDownLatch, CyclicBarrier, Semaphore等,这些工具提供了比synchronized更丰富的功能,并且在很多情况下拥有更好的性能。
- 使用原子变量类:如AtomicInteger, AtomicLong, AtomicReference等,它们提供了一种无锁的方式来处理共享数据,基于现代CPU的CAS(Compare-And-Swap)特性实现,既保证了原子性也保证了有序性和可见性。
- 使用final关键字:对于对象引用和不可变对象,使用final字段可以在对象构造期间保护其初始化的安全性,确保对象一旦构造完成,其状态就不会再受到重排序的影响。
volatile关键字
volatile关键字在Java中用于确保变量的可见性和禁止某些类型的指令重排序,它主要用于解决多线程环境下的可见性问题。
可见性
- 当一个线程对volatile变量进行写操作时,这个写操作会立即被刷新到主内存中,而不是停留在处理器缓存中。
- 当另一个线程随后读取同一个volatile变量时,它会从主内存中获取最新的值,而不是使用处理器缓存中的副本。
这意味着,通过使用volatile关键字声明的变量可以保证一个线程对变量所做的修改能够立即对其他线程可见,解决了由于缓存导致的可见性问题。
禁止指令重排序
- volatile还提供了一种“happens-before”的关系,确保了对该变量的操作不会被编译器或处理器重排序到不恰当的位置。
更具体地说,对volatile变量的写入操作必须在任何后续对该变量的读取操作之前完成(根据程序顺序)。这有助于避免由于指令重排序导致的竞态条件或其他并发问题。
happens-before
概念
“Happens-before”是Java内存模型(JMM)中的一个概念,用于描述程序中操作之间的部分有序关系,确保多线程环境下的可见性和有序性。简单来说,“happens-before”规则定义了一个操作何时以及如何对另一个操作可见。如果操作A “happens-before” 操作B,则A所做的所有影响(包括变量的写入)都必须在B执行时可见。
规则
- 程序顺序规则:每个线程内部的操作按照程序代码的顺序执行,“happens-before”于该线程后续的操作。尽管编译器和处理器可以对指令进行重排序优化,但这些重排序不能改变单个线程内的程序顺序。
- 监视器锁规则:对同一个锁的解锁操作“happens-before”随后对该锁的加锁操作。这意味着在一个线程释放锁之后对共享变量所做的任何修改,在其他线程获取到这个锁后都是可见的。
- volatile字段规则:对volatile变量的写入操作“happens-before”所有后续对该变量的读取操作。这保证了当一个线程写入一个volatile变量后,所有其它线程都能看到这个写入动作的效果。
- 线程启动规则:Thread.start()方法调用“happens-before”由该线程执行的所有操作。这意味着主线程在调用新线程的start()方法之前所做的任何初始化工作对于新线程来说都是可见的。
- 线程结束规则:由某个线程执行的所有操作“happens-before”其他线程通过Thread.join()成功返回。即,一个线程等待另一个线程结束,并且能够看到那个线程所有的操作结果。
- 传递性:如果操作A “happens-before” 操作B,而操作B又 “happens-before” 操作C,那么操作A “happens-before” 操作C。
并发设计模式
概念
并发设计模式是专门用来解决多线程编程和并发计算中遇到的问题的设计模式。它们帮助开发者更好地管理资源共享、任务分配以及线程间通信,从而提高程序的性能和可靠性
分类
生产者-消费者模式(Producer-Consumer Pattern):
- 这是一种经典的并发模式,用于解耦数据生产者和消费者。它通过一个共享队列来实现,生产者将数据放入队列,而消费者从队列中取出数据进行处理。
读者-写者问题(Readers-Writers Problem):
- 解决了当存在多个读取者和写入者同时访问共享资源时的问题。目标是在最大化读操作并发性的同时保证写操作的数据一致性。
单例模式(Singleton with Double-Checked Locking):
- 虽然单例模式本身不是专门为并发环境设计的,但在多线程环境下使用双重检查锁定机制可以确保只有一个实例被创建,同时减少同步带来的开销。
锁与条件变量(Locks and Condition Variables):
- 提供了一种机制让线程之间能够相互等待直到某个条件成立。这通常涉及到互斥锁(mutex)和条件变量的使用。
信号量(Semaphore):
- 用于控制对有限资源的访问,如限制数据库连接池中的最大连接数等。二元信号量(binary semaphore)特别类似于互斥锁。
屏障(Barrier):
- 允许一组线程全部到达某一点后继续执行。这对于需要所有线程完成当前阶段才能开始下一阶段的任务非常有用。
Future模式:
- 允许异步执行任务,并在将来某个时刻获取结果。Java中的Future接口和CompletableFuture类就是这种模式的实现。
工作窃取(Work Stealing):
- 在任务并行库(TPL)或类似框架中,工作窃取算法使得空闲线程可以从忙碌线程的工作队列末尾“偷取”任务来执行,以平衡负载。
Balking模式:
- 当一个操作正在进行中时,阻止其他线程再次尝试执行相同的操作。例如,在自动保存场景下,如果用户已经手动保存,则自动保存过程会被取消。
两阶段终止模式(Two-Phase Termination Pattern):
- 为了解决如何优雅地停止线程的问题,包括通知线程准备停止,并给线程机会清理资源。
无锁编程
概念
无锁编程(Lock-Free Programming)是一种并发编程技术,旨在避免使用传统的锁机制来保护共享资源,从而减少死锁、优先级反转和性能瓶颈等问题。无锁算法通常依赖于原子操作(如比较并交换 Compare-And-Swap, CAS)来实现同步,确保即使在高并发条件下也能保证数据的一致性和正确性。
主要技术
1. 原子操作
原子操作是无锁编程的基础,它保证了一系列操作要么全部执行完成,要么完全不执行,不会出现部分执行的情况。常见的原子操作包括:
- Compare-And-Swap (CAS):检查一个内存位置的值是否等于预期值,如果是,则用新值替换旧值。这是实现无锁算法最常用的原子操作。
- Fetch-And-Add:对指定内存位置的值进行原子加法操作,并返回该位置的原值。
- Load-Linked/Store-Conditional (LL/SC):一种替代CAS的方法,首先通过Load-Linked读取一个值,然后尝试用Store-Conditional写入一个新值,只有在这两个操作之间没有其他线程修改过该位置时,写入才会成功。
2. 内存屏障/栅栏(Memory Barriers/Fences)
- 内存屏障是一种同步指令,用于控制指令重排序和确保内存操作的顺序性。它们帮助程序员强制某些内存操作按照特定顺序执行,这对于维护多线程环境下的正确性至关重要。内存屏障分为几种类型,如加载屏障、存储屏障、全屏障等。
3. ABA问题解决方案
ABA问题是无锁编程中的一个经典挑战,指的是某个变量先从A变为B再变回A,导致依赖于值比较的操作(如CAS)产生错误的行为。解决ABA问题的技术包括:
- 版本号计数:为每个需要保护的数据项附加一个版本号或时间戳。每次更新数据项时也更新其版本号,这样即使值回到了原来的A,版本号也会不同,从而可以被检测到。
- 指针标记:在指针上添加额外的信息(例如低位作为标记),使得即使指针指向的对象地址相同,但标记位不同也能区分不同的状态。
4. 设计高效的无锁数据结构
设计高效的无锁数据结构是无锁编程的核心任务之一。常见的无锁数据结构包括但不限于:
- 无锁栈(Lock-Free Stack):通过使用CAS操作来安全地推入和弹出元素。
- 无锁队列(Lock-Free Queue):如Michael-Scott非阻塞队列算法,它允许多个线程同时进行入队和出队操作而不互相干扰。
- 无锁哈希表(Lock-Free Hash Table):通过细粒度锁定或者基于CAS的操作来管理哈希冲突和扩容。
5. 非阻塞同步策略
除了上述具体的技术外,还有一些高层次的设计策略可以帮助构建无锁系统:
- 乐观并发控制(Optimistic Concurrency Control, OCC):假设冲突很少发生,在提交更改之前检查是否有冲突;如果发现冲突,则回滚并重试。
- 惰性同步(Lazy Synchronization):推迟某些同步操作直到绝对必要时才执行,以减少同步开销。
为什么无锁效率高
1. 减少上下文切换
在基于锁的并发控制中,如果一个线程持有锁而其他线程尝试获取相同的锁时,那些未能获得锁的线程会被阻塞,这意味着它们必须等待直到锁可用。这种等待会导致操作系统进行线程上下文切换,这是一项昂贵的操作,因为它涉及保存当前线程的状态并加载另一个线程的状态。
相比之下,无锁算法允许所有线程继续运行,即使某个操作失败了(例如 compareAndSet 返回 false),它也只是简单地重试,而不是被阻塞或暂停执行。这样可以显著减少不必要的上下文切换,从而提高系统整体性能。
2. 避免死锁和优先级反转
使用传统的锁机制时,可能会遇到复杂的问题如死锁(Deadlock)和优先级反转(Priority Inversion)。这些问题可能导致部分或全部线程永久挂起,严重影响系统的响应性和稳定性。无锁算法通过消除锁的使用自然避免了这些问题的发生。
3. 更好的可伸缩性
随着处理器核心数量的增加,能够有效利用多核架构变得至关重要。无锁算法由于不依赖于全局锁,因此可以在多个核心上同时执行不同的任务而不互相干扰,从而更好地利用硬件资源。相反,基于锁的设计可能会成为瓶颈,尤其是在高争用的情况下,因为所有的线程都试图访问同一个锁。
4. 原子操作的高效性
现代CPU提供了对原子操作的强大支持,比如 Compare-And-Swap (CAS) 操作。这些原子指令能够在硬件层面保证某些类型的同步操作是原子性的,无需额外的软件层同步开销。虽然单个 CAS 操作可能并不总是成功,但它的失败处理非常轻量级——通常是立即重试或者根据策略短暂等待后再重试,而不是像锁那样导致线程进入等待状态。
5. 降低锁争用带来的开销
在高度竞争的环境中,锁争用会导致严重的性能下降。每个试图获取已被占用的锁的尝试都会失败,并且线程不得不等待。而在无锁算法中,尽管也可能存在“竞争”——即多个线程同时尝试修改同一数据结构的情况,但是这种竞争是以非阻塞的方式处理的,允许失败的操作快速重试,而不是陷入长时间的等待。
CAS是什么
CAS是一种硬件提供的原子操作,它接受三个参数:内存位置(V)、预期原值(A)和新值(B)。这个操作的功能是仅当内存位置V的当前值等于预期原值A时,才将V的值更新为新值B,并返回操作是否成功的布尔值。如果在此期间另一个线程改变了V的值,使得其不再等于A,则该操作失败,通常会重试。
1 | public final boolean compareAndSet(int expectedValue, int newValue) { |
账户余额管理功能案例分析
账户接口
1 | interface Account { |
线程不安全的情况
1 | package cn.itcast.cas; |
通过锁实现线程安全
1 | public class AccountSafeImplSychronized implements Account{ |
无锁实现线程安全
1 | public class AccountSafeImpl implements Account{ |
测试:
1 | // 创建账户 |
分析无锁实现线程安全:
1 |
|
- 使用 while(true) 循环不断尝试更新余额,直到成功为止。
- compareAndSet(prev, next) 方法用于比较当前余额是否为预期值 (prev),如果是,则将余额设置为新值 (next)。如果不是,则说明在此期间有其他线程修改了余额,因此当前线程会再次尝试。
- 这种方式确保了即使在并发环境中,余额的更新也能正确完成,不会出现数据竞争或其他一致性问题。
源码分析:
看一下AtomicInteger底层实现:
可以发现通过AtomicInteger包裹整型,底层使用volatile关键字,保证了可见性和禁止指令重排序。
1 | private volatile int value; |
compareAndSet方法
1 | public final boolean compareAndSet(int expect, int update) { |
该代码实现了原子级别的比较并设置操作:
- 如果当前值等于期望值,则将值更新为新值,并返回true。
- 如果当前值不等于期望值,则不进行更新,返回false。
- 使用Unsafe类的compareAndSwapInt方法实现底层的CAS(Compare-And-Swap)操作。
1 | public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5); |
如有错误,欢迎指正!!!