并发编程(锁)
锁是用于控制多个线程对共享资源的访问的机制,防止出现程序对共享资源的竞态关系
线程安全#
在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常且正确的执行,不会出现数据污染等意外情况
线程的竞态条件#
竞态条件(race condition)
竞态条件(race condition)指的是两个或者以上进程或者线程并发执行时,其最终的结果依赖于进程或者线程执行的精确时序。竞争条件会产生超出预期的情况,一般情况下我们都希望程序执行的结果是符合预期的,因此竞争条件是一种需要被避免的情形。
竞争条件分为两类:
- Mutex(互斥):两个或多个进程彼此之间没有内在的制约关系,但是由于要抢占使用某个临界资源(不能被多个进程同时使用的资源,如打印机,变量)而产生制约关系。
- Synchronization(同步):两个或多个进程彼此之间存在内在的制约关系(前一个进程执行完,其他的进程才能执行),如严格轮转法。
要阻止出现竞态条件的关键就是不能让多个进程/线程同时访问那块共享变量。访问共享变量的那段代码就是临界区(critical section)。所有的解决方法都是围绕这个临界区来设计的。
线程安全的三大特性#
1、原子性(Atomicity):原子操作是不可分割的操作,要么全部执行成功,要么全部不执行。在多线程环境下,如果多个线程同时执行原子操作,不会出现数据不一致的情况。例如,使用synchronized关键字或者Lock接口来保证关键代码块的原子性。
2、可见性(Visibility):可见性是指当一个线程修改了共享变量的值,其他线程能够立即看到修改后的值。在多线程环境下,需要保证共享变量的修改对其他线程是可见的,通常可以使用volatile关键字来实现可见性。
3、有序性(Ordering):有序性是指程序的执行顺序按照代码的先后顺序来执行,不会因为编译器的优化或者CPU的乱序执行而导致结果的不确定。在多线程环境下,需要保证共享变量的读写操作是有序的,通常可以通过synchronized关键字或volatile关键字来实现有序性。
Java的内存模型#
了解更多:https://www.51cto.com/article/658158.html
我们都知道JVM中每个线程都有自己的栈空间,共享变量会存放在主内存中。在并发修改变量的过程中线程可能会发生挂起,导致写入到主内存的值发生覆盖。导致破坏了原子性
as-if-serial语义和happen-before原则#
1、as-if-serial语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序
2、JVM定义的Happens-Before原则是一组偏序关系:对于两个操作A和B(共享数据),这两个操作可以在不同的线程中执行。如果A Happens-Before B,那么可以保证,当A操作执行完后,A操作的执行结果对B操作是可见的
3、as-if-serial 和 happens-before 的区别
- as-if-serial是针对单线程程序的执行结果的一致性,允许虚拟机对单线程程序进行指令重排序,只要不改变执行结果。
- happens-before是针对多线程程序的执行顺序的一致性,描述了多线程之间操作的可见性和顺序关系。
- as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度
volatile关键字#
volitale是Java虚拟机提供的一种轻量级的同步机制
1、保证可见性
可见性主要存于JMM的内存模型当中,指当一个线程改变其内部的工作内存当中的变量后,其他内存是否可以感知到,因为不同的工作线程无法访问到对方的工作内存,线程间的通信必须依靠主内存进行同步
2、不保证原子性
当在多线程改变变量时,需要将变量同步到工作线程中;当线程A对变量修改时,还没同步到主内存中线程挂起,线程B也对变量进行修改,这时线程A进行执行,就会覆盖线程B的值,由于可见性,这时线程B也会变成线程A修改的值,导致一致性问题
3、禁止指令重排
在本线程内观察,所有操作都是有序的(即指令重排不会导致单线程程序执行结果与排序前有任何差别)。在一个线程观察另一个线程,所有操作都是无序的,无序是因为发生了指令重排序。在 Java 内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
想要线程安全必须保证原子性,可见性,有序性。而volatile只能保证可见性和有序性
内存屏障#
- Memory barrier 能够让CPU或编译器在内存访问上有序。一个 Memory barrier 之前的内存访问操作必定先于其之后的完成。
- Memory barrier是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。
- 有的处理器的重排序规则较严,无需内存屏障也能很好的工作,Java编译器会在这种情况下不放置内存屏障。
Memory Barrier可以被分为以下几种类型:
1、LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
2、StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
3、LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
4、StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能
volatile语义中的内存屏障
在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障;
在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障;
volatile的内存屏障策略非常严格保守,保证了线程可见性。
final语义中的内存屏障
新建对象过程中,构造体中对final域的初始化写入(StoreStore屏障)和这个对象赋值给其他引用变量,这两个操作不能重排序;
初次读包含final域的对象引用和读取这个final域(LoadLoad屏障),这两个操作不能重排序;
Intel 64/IA-32架构下写操作之间不会发生重排序StoreStore会被省略,这种架构下也不会对逻辑上有先后依赖关系的操作进行重排序,所以LoadLoad也会变省略。
锁的类型#
从线程是否需要对资源加锁可以分为
悲观锁(系统)
和乐观锁(CAS)
从资源已被锁定,线程是否阻塞可以分为自旋锁(CAS)
从多个线程并发访问资源,也就是 Synchronized 可以分为无锁
、偏向锁
、轻量级锁
和重量级锁
从锁的公平性进行区分,可以分为公平锁
和非公平锁
从根据锁是否重复获取可以分为可重入锁
和不可重入锁
从那个多个线程能否获取同一把锁分为共享锁(读锁)
和排他锁(写锁)
类锁和对象锁(监视器锁)#
类锁是加载类上的,而类信息是存在 JVM 方法区的,并且整个 JVM 只有一份,方法区又是所有线程共享的,所以类锁是所有线程共享的
类锁是指对静态方法或静态变量加锁时所产生的锁。类锁是类上的,而类信息是存在 JVM 方法区的,并且整个 JVM 只有一份,方法区又是所有线程共享的;当一个线程获取了一个类锁,其他线程就无法同时获取该类的锁,直到该线程释放了锁。
对象锁是指对非静态方法或非静态变量加锁时所产生的锁。每个对象都有自己的对象锁,当一个线程获取了某个对象的锁,其他线程就无法同时获取该对象的锁,直到该线程释放了锁
读写锁
Exclusive Lock(独占锁)是一种互斥锁,同一时间只有一个线程可以获取该锁。当线程A获取到Exclusive Lock后,其他线程B、C等就不能再获取该锁,只能等待线程A释放锁后才能尝试获取。在Java中,ReentrantLock就是一种独占锁。
Shared Lock(共享锁)是一种允许多个线程同时获取的锁,通常用于读取操作,可以提高并发读取效率。当线程A获取到Shared Lock后,其他线程B、C等仍然可以获取该锁,只有当所有线程都释放了该锁后,该锁才算被完全释放。在Java中,ReadWriteLock就是一种支持读写分离的锁,其中读锁是共享锁,写锁是独占锁。
自旋锁#
优点:自旋锁不会引起调用者休眠,如果自旋锁已经被别的线程保持,调用者就一直循环在那里看是否该自旋锁的保持者释放了锁。由于自旋锁不会引起调用者休眠,所以自旋锁的效率远高于互斥锁
自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是 active 的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。 (线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)
缺点:
1、自旋锁一直占用 CPU ,在未获得锁的情况下,一直运行,如果不能在很短的时间内获得锁,会导致 CPU 效率降低。
2、试图递归地获得自旋锁会引起死锁。递归程序决不能在持有自旋锁时调用它自己,也决不能在递归调用时试图获得相同的自旋锁。
class Spinlock {
private AtomicReference<Thread> cas;
spinlock(AtomicReference<Thread> cas){
this.cas = cas;
}
public void lock() {
Thread current = Thread.currentThread();
// 利用CAS
while (!cas.compareAndSet(null, current)) {
// DO nothing
System.out.println("I am spinning");
}
}
public void unlock() {
Thread current = Thread.currentThread();
cas.compareAndSet(current, null);
}
}
CAS自旋
CAS(Compare and Swap)是一种基于原子操作的并发控制机制,用于实现多线程之间的同步。CAS操作包含三个操作数,分别为内存位置V、期望值A和新值B。当且仅当V的值等于A时,CAS将V的值设为B,否则不做任何操作。
CAS 是一条 CPU 的原子指令(cmpxchg指令),不会造成所谓的数据不一致问题,Unsafe类提供的 CAS 方法(如compareAndSwapXXX)底层实现即为CPU指令cmpxchg
AtomicInteger
:用于原子性地操作整数型变量。AtomicLong
:用于原子性地操作长整型变量。AtomicBoolean
:用于原子性地操作布尔型变量。AtomicReference
:用于原子性地操作引用类型变量。AtomicIntegerArray
:用于原子性地操作整数型数组。AtomicLongArray
:用于原子性地操作长整型数组。AtomicReferenceArray
:用于原子性地操作引用类型数组。
ABA问题
在多线程编程中,常常会遇到ABA问题。简单来说,ABA问题就是指线程A读取了共享变量V的值,然后线程B将V的值改为了其他值,再次改回了原来的值,然后线程A又进行了写操作。这种情况下,线程A会认为V的值没有发生变化,但实际上V的值已经发生了变化。
为了解决ABA问题,Java中提供了一个带有时间戳的原子类AtomicStampedReference。AtomicStampedReference类可以通过增加时间戳来解决ABA问题,时间戳的作用是记录每一次变量的修改操作,使得在比较并交换时不仅需要比较变量的值,还需要比较变量的时间戳是否相同。这样就可以避免ABA问题的发生。
具体来说,AtomicStampedReference类中的compareAndSet方法不仅会比较当前值和期望值是否相等,还会比较当前的时间戳是否相等。只有当前值和时间戳都相等时,才会执行CAS操作,否则不会执行。
示例:
在多线程场景下CAS会出现ABA问题,关于ABA问题这里简单科普下,例如有2个线程同时对同一个值(初始值为A)进行CAS操作,这三个线程如下
线程1,期望值为A,欲更新的值为B
线程2,期望值为A,欲更新的值为B
线程1抢先获得CPU时间片,而线程2因为其他原因阻塞了,线程1取值与期望的A值比较,发现相等然后将值更新为B,然后这个时候出现了线程3,期望值为B,欲更新的值为A,线程3取值与期望的值B比较,发现相等则将值更新为A,此时线程2从阻塞中恢复,并且获得了CPU时间片,这时候线程2取值与期望的值A比较,发现相等则将值更新为B,虽然线程2也完成了操作,但是线程2并不知道值已经经过了A->B->A的变化过程。
参考理解:https://juejin.cn/post/6844903796129136647
Unsafe类
Unsafe是位于sun.misc包下的一个类,主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,这些方法在提升Java运行效率、增强Java语言底层资源操作能力方面起到了很大的作用
如何调用Unsafe类
1、从getUnsafe
方法的使用限制条件出发,通过Java命令行命令-Xbootclasspath/a
把调用Unsafe相关方法的类A所在jar包路径追加到默认的bootstrap路径中,使得A被引导类加载器加载,从而通过Unsafe.getUnsafe
方法安全的获取Unsafe实例。
java -Xbootclasspath/a: ${path} // 其中path为调用Unsafe相关方法的类所在jar包路径
2、通过反射获取单例对象theUnsafe。
private static Unsafe reflectGetUnsafe() {
try {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
return (Unsafe) field.get(null);
} catch (Exception e) {
log.error(e.getMessage(), e);
return null;
}
}
锁升级
在Java中,锁可以分为四个级别:无锁、偏向锁、轻量级锁、重量级锁和GC锁。锁的级别会根据竞争情况自动升级,以保证性能和可靠性的平衡。下面是锁升级的过程:
1、无锁:无锁态
2、偏向锁(Biased Locking)
偏向锁是在对象创建时默认启用的锁,它会将锁标记为偏向状态,即锁对象只会被一个线程占用。如果其他线程需要访问锁对象,则会撤销偏向锁,将锁升级为轻量级锁。
3、轻量级锁(Lightweight Locking)
轻量级锁是当多个线程访问同一对象的锁时启用的锁,它的实现基于CAS(Compare And Swap)操作和自旋。在竞争不激烈的情况下,轻量级锁可以避免线程阻塞和上下文切换的开销,提高程序的性能。如果自旋失败,则锁升级为重量级锁。
4、重量级锁(Heavyweight Locking)
重量级锁是在多个线程频繁竞争同一对象的锁时启用的锁,它会将其他线程阻塞,等待持有锁的线程释放锁。重量级锁的实现基于操作系统提供的互斥量(Mutex)或信号量(Semaphore),因此在竞争激烈的情况下,重量级锁的性能会比轻量级锁差很多。
5、C锁(GC Locking)
GC锁是在Java垃圾收集器执行垃圾回收时启用的锁,它会将所有线程阻塞,等待垃圾收集完成。由于GC锁是由操作系统实现的,因此在竞争激烈的情况下,GC锁的性能也会很差。
锁消除
锁消除即删除不必要的加锁操作。JVM在运行时,对一些“在代码上要求同步,但是被检测到不可能存在共享数据竞争情况的锁进行消除。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么就可以认为这段代码是线程安全的,无需加锁
public class LockClearTest {
public static void main(String[] args) {
LockClearTest test = new LockClearTest();
for (int i = 0; i < 100000; i++) {
test.append("aaa", "bbb");
}
}
public void append(String str1, String str2) {
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append(str1).append(str2);
}
}
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
锁粗化
一系列的连续操作都会对同一个对象反复加锁及解锁,甚至加锁操作是出现在循环体中的,即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
如果JVM检测到有一连串零碎的操作都是对同一对象的加锁,将会扩大加锁同步的范围(即锁粗化)到整个操作序列的外部。
public class StringBufferTest {
StringBuffer stringBuffer = new StringBuffer();
public void append(){
stringBuffer.append("a").append("b").append("c");
}
}
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
死锁(deadlock)
两个或两个以上的线程持有不同系统资源的锁,线程彼此都等待获取对方的锁来完成自己的任务;下面是死锁示例:
public class DeadlockExample {
private static Object lock1 = new Object();
private static Object lock2 = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Thread 1: Holding lock 1...");
try {
Thread.sleep(100);
} catch (InterruptedException e) {}
System.out.println("Thread 1: Waiting for lock 2...");
synchronized (lock2) {
System.out.println("Thread 1: Holding lock 1 and lock 2...");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("Thread 2: Holding lock 2...");
try {
Thread.sleep(100);
} catch (InterruptedException e) {}
System.out.println("Thread 2: Waiting for lock 1...");
synchronized (lock1) {
System.out.println("Thread 2: Holding lock 1 and lock 2...");
}
}
});
thread1.start();
thread2.start();
}
}
在这个示例中,有两个线程分别持有lock1和lock2,并试图获取对方持有的锁。这将导致死锁,因为当一个线程持有lock1并等待lock2时,另一个线程持有lock2并等待lock1,它们将永远无法释放对方需要的锁
Java 死锁侦测
JDK
自带了一些简单好用的工具,可以帮助我们检测死锁(如:jstack
)。
$ jstack $(jps -l | grep 'DeadLockExample' | cut -f1 -d ' ')
代码清单:使用
jstack
侦测目标 JVM 进程
Java stack information for the threads listed above:
===================================================
"Thread-1":
at DeadLockExample$2.run(DeadLockExample.java:58)
- waiting to lock <0x000000076ab660a0> (a java.lang.Object)
- locked <0x000000076ab660b0> (a java.lang.Object)
at java.lang.Thread.run(Thread.java:748)
"Thread-0":
at DeadLockExample$1.run(DeadLockExample.java:28)
- waiting to lock <0x000000076ab660b0> (a java.lang.Object)
- locked <0x000000076ab660a0> (a java.lang.Object)
at java.lang.Thread.run(Thread.java:748)
Found 1 deadlock.
可以看到,jstack
已经侦测出 JVM 进程中存在线程死锁的情况,并为我们打印出了相关信息。
JVM 图形化分析工具jconsole
也可以帮助我们分析死锁情况。
Java 死锁预防
- 以确定的顺序获锁
- 超时放弃
- 死锁检测
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· Obsidian + DeepSeek:免费 AI 助力你的知识管理,让你的笔记飞起来!
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了