【从零开始学习JAVA | 第四十一篇】深入JAVA锁机制
目录
前言:
在多线程编程中,线程之间的协作和资源共享是一个重要的话题。当多个线程同时操作共享数据时,就可能引发数据不一致或竞态条件等问题。为了解决这些问题,Java提供了强大的锁机制,使得多线程程序能够安全地共享资源、实现线程间的同步。
Java锁机制允许我们控制多个线程对共享资源的访问,确保在任何时刻只有一个线程可以访问公共数据或执行特定的代码块。这种机制既可以用于保护共享变量的一致性,也可以用于实现对临界区的互斥访问。
引入:
在锁机制没有出现以前,多线程往往会出现以下两个问题:
数据不一致:当多个线程同时读写共享数据时,可能会出现数据不一致的情况。比如,一个线程正在对某个变量执行修改操作,而另一个线程同时读取该变量的值,如果没有锁机制的保护,可能会读取到未被修改之前的旧值,导致数据出现不一致的情况。
假设有两个线程同时对共享的变量进行读取和修改操作:
int sharedVariable = 0;// 线程1的代码sharedVariable = 10;// 线程2的代码int value = sharedVariable;System.out.println(value);
在上述代码中,线程1将共享变量 sharedVariable
的值修改为10。同时,线程2读取共享变量 sharedVariable
的值并打印出来。如果没有适当的同步机制,线程2可能会读取到修改之前的旧值0,导致数据不一致的情况
竞态条件:当多个线程同时对共享资源进行修改操作时,由于线程之间执行顺序的不确定性,可能导致执行结果依赖于线程执行时的相对顺序。这种不确定性可能引发竞态条件,导致程序出现错误的行为。例如,多个线程同时对同一个计数器进行自增操作,如果没有适当的同步机制,就可能出现计数器值不正确的情况。
假设有两个线程同时对一个计数器进行自增操作:
int counter = 0;// 线程1的代码counter++;// 线程2的代码counter++;
在上述代码中,如果没有适当的同步机制,两个线程在执行 counter++
操作时可能会发生线程切换,导致线程1和线程2之间的执行顺序不确定。这种情况下,如果线程1先执行自增操作,然后线程2执行自增操作,最终计数器的值可能只增加了1,而不是期望的2。这就是典型的竞态条件,导致了程序行为的错误。
因此我们需要一种方法,可以在其他线程被调用的时候对调用数据进行保护,所以我们创造出了锁机制,通过使用锁机制,可以解决这些问题。锁机制可以确保在某个线程修改共享数据时,其他线程无法同时进行读取或修改操作,从而避免了数据不一致和竞态条件的发生。
在正式介绍锁机制之前,我们还是先来认识一下JVM运行时的内存结构:
在这张图中,我们需要知道
- 绿色部分是所有线程共享的数据区域
- 黄色部分是每一个线程单独享有的数据区域
那让我们开始正式的介绍锁:
锁机制:
在JAVA中,每一个对象都有一把锁,这把锁被存放在对象头中,锁中记录了当前对象被哪个线程占用。
对象的组成部分:
对象头(Object Header):对象头包含了一些用于存储对象元数据的信息,如对象的哈希码、锁的信息、GC(垃圾回收)相关的标记等。对象头的大小在不同的Java虚拟机实现中会有所差异。
实例数据(Instance Data):实例数据是对象的成员变量的实际存储空间。它们是对象的状态的一部分,也就是我们定义在类中的成员变量。实例数据的大小取决于对象的成员变量数量和类型。
对齐填充(Alignment Padding):为了提高内存访问的效率,Java虚拟机要求对象的起始地址必须是某个特定值的倍数。如果实例数据的大小不是这个特定值的倍数,就需要通过填充字节来对齐。
OK,那我们开始讲解我们学习中遇到的第一个锁
synchronized
在Java中,当使用synchronized关键字修饰方法或代码块时,编译后会生成两条字节码指令:monitorenter
和monitorexit
。这两条指令用于获取锁和释放锁,实现线程同步。
-
monitorenter
指令:该指令用于获取对象的锁(内置锁)。当线程执行到被synchronized
修饰的代码块或方法时,它首先会尝试获取对象的锁。如果该锁没有被其他线程持有,该线程就会成功获取锁,并继续执行下面的指令。如果锁被其他线程持有,则当前线程会进入阻塞状态,直到锁被释放。 -
monitorexit
指令:该指令用于释放对象的锁。当线程执行完synchronized
修饰的代码块或方法后,或者发生了异常退出时,该线程会释放对象的锁。这样可以确保其他线程能够获取锁并执行相关的代码。
示例:
public class MyClass { private final Object lock = new Object(); public void synchronizedMethod() { synchronized (lock) { // 被synchronized修饰的代码块 } }}
对应的编译后的字节码指令如下:
0: aload_0 ; 将当前对象加载到操作数栈1: getfield #1 ; 加载对象的字段(锁对象)4: dup ; 复制栈顶元素(锁对象)5: astore_1 ; 将锁对象存储到局部变量6: monitorenter ; 进入同步块获取锁7: ; 执行同步块的代码8: aload_1 ; 加载局部变量(锁对象)9: monitorexit ; 退出同步块释放锁
这些字节码指令确保了在synchronized修饰的代码块中,只能有一个线程执行,并保证了线程之间的互斥性和正确的内存同步。这样可以确保多个线程安全地访问共享资源,避免并发问题。
而这就是synchronized的运行机制,通过两个字节码来实现对线程的同步机制。
但遗憾的是synchronized存在性能问题,因为他被编译后实际上就是两个字节码指令,而这两个字节码文件都是依赖于操作系统的mutex lock进行的,而JAVA线程本质上就是对操作系统线程的映射。因此每当操作或挂起一个线程,都要对操作系统内核态进行切换,而这种操作太费时间了,在一切情况下甚至切换的时间都超过了应用的时间。
而从JAVA6开始,就对synchronized进行了优化,引入了偏向锁和轻量级锁。
此时锁就一共有四种了:
无锁,偏向锁,轻量级锁,重量级锁
-
无锁(Lock-Free):无锁是一种并发控制机制,允许多个线程同时修改共享资源,而不需要显式地使用锁。无锁的算法通常通过使用原子操作(如CAS,Compare and Swap)来保证多线程操作的原子性和线程安全性。无锁的目标是通过无竞争的方式实现最大的并发性能。
-
偏向锁(Biased Locking):偏向锁是JVM针对没有竞争的场景进行的一种锁优化机制。它的目标是减少无竞争情况下的锁操作开销。在偏向锁状态下,当一个线程访问锁时,JVM会将锁对象的标记置为偏向线程ID,之后该线程再次访问锁时就不会再进行同步操作,从而提高性能。
-
轻量级锁(Lightweight Locking):轻量级锁是针对竞争不激烈的情况下的一种锁优化机制。它通过使用CAS操作来进行锁定和释放,而不需要进行互斥的内核态操作。当一个线程尝试获取轻量级锁时,它会使用CAS操作将对象头中的标志位更新为锁记录(Lock Record)指向的线程ID。如果操作成功,这个线程就可以继续执行临界区代码;如果操作失败,说明存在竞争,需要升级为重量级锁。
-
重量级锁(Heavyweight Locking):重量级锁是传统的锁机制,也是默认的锁实现。当多个线程竞争一个锁时,JVM会将该锁从轻量级锁升级为重量级锁。重量级锁会在操作系统层面进行互斥的内核态操作,如使用互斥量等。它能确保多个线程之间的互斥性,但也会带来更多的开销。
这四个状态是递增的,无锁->偏向锁->轻量级锁->重量级锁。而这种状态可以升级也可以降级。
在我们学习了互斥锁的底层机制,互斥锁的四种状态之后 我们在来介绍一下
CAS算法:
CAS(Compare and Swap)是一种用于实现无锁算法的同步原语。它主要用于多线程环境下对共享数据的原子操作,提供了一种线程安全的方式来进行数据的更新。
CAS 算法涉及三个操作数:内存地址(V)、旧的预期值(A)和新的值(B)。CAS 算法的执行过程如下:
-
首先,线程读取内存地址 V 中的值,记为当前值 currentV。
-
然后,线程检查当前值 currentV 是否等于预期值 A。如果相等,说明没有其他线程修改过该值,线程可以进行更新操作。
-
如果当前值 currentV 不等于预期值 A,说明有其他线程修改过该值,线程不进行更新操作。可以选择重试或采取其他策略来处理。
-
如果当前值 currentV 等于预期值 A,线程将新的值 B 写入到内存地址 V 中。
-
最后,线程判断写入操作是否成功。如果成功,说明更新操作完成;如果不成功,说明有其他线程在该线程之前执行了更新操作,需要重新执行整个 CAS 算法。
CAS 算法的核心思想是通过比较当前值和预期值是否相等来判断共享数据是否被修改过。如果没有被修改过,就进行更新操作;如果被修改过,说明有其他线程先一步修改了数据,需要重试。因此,CAS 算法可以避免传统锁所带来的线程阻塞和上下文切换的开销,增加了并发性能。
然而,CAS 算法也存在一些问题,例如ABA 问题(两次读取的值是一样的,但是中间过程发生了变化)以及循环时间长开销大等。为了解决这些问题,Java 提供了 Atomic 包下的一些原子类,如 AtomicReference 和 AtomicStampedReference,可以解决 CAS 算法中的ABA 问题,并提供了更高级的封装和功能。
我们用图片来演示一下CAS算法:
A和B就代表两条线程,而C就代表此时A和B争抢的资源文件,如果A线程运气好抢到了资源C,他将会把自身的old value 与C进行对比,如果一致,就把C的状态改为1,并且获得对C的操作权。
而此时B再与C进行比较,0!=1,因此B就会放弃swap操作,但是在实际操作中,B并不会就直接放弃,而是让其进行自旋,所谓的自旋就是不断进行CAS操作,假如C的状态后面变为0,B就又会重新进行比较和交换操作
下面我们用一段代码来展示一下CAS函数:
int cas(long * addr ,long oldvalue,long newvalue){ if(*addr != old) return 0; *addr= new ; return 1;}
其实这段代码还是有问题的,CAS分为两部分:compare 和 swap ,那么既然这个方法没有进行任何同步操作,那如果A线程获得时间片但对C状态修改的时候,B线程又获得了时间片,此时不就是两个线程AB同时获得了对资源数据的操作权力吗?
但好在CAS早就已经通过底层设计,将赋予了其原子性。
最后我们再介绍一下乐观锁与悲观锁
乐观锁与悲观锁:
乐观锁和悲观锁是两种并发控制的思想,主要应用于多线程环境下对共享数据的访问控制。它们的主要区别在于对于并发冲突的处理策略和机制。
- 悲观锁:
悲观锁的思想是假设在整个数据操作过程中其他线程可能会修改数据,因此在对数据进行操作时默认认为会发生冲突,所以采取阻塞等待的方式。悲观锁主要通过线程阻塞、锁定共享资源等方式来保证同一时间只有一个线程能够访问共享数据。
常见的悲观锁实现包括:
- 互斥锁(如 Java 中的 synchronized 关键字、ReentrantLock):使用互斥锁来保证对共享资源的独占访问,其他线程需要等待锁释放才能访问。
- 读写锁(如 Java 中的 ReentrantReadWriteLock):通过区分读操作和写操作,允许多个线程同时读取共享资源,但只允许单个线程进行写操作。
- 乐观锁:
乐观锁的思想是假设在整个数据操作过程中不会发生并发冲突,因此不采取阻塞等待的方式,而是在更新数据时检查是否发生冲突。如果发现冲突,则采取相应的策略(如重试或放弃更新)。乐观锁通常使用无锁算法(如 CAS)来实现。
乐观锁在大多数情况下用的是CAS无锁算法,因此不要看见锁这个字就认为乐观锁是锁!
常见的乐观锁实现包括:
- 版本号(Versioning):在数据记录中加入版本号字段,每次更新时通过比较版本号判断是否发生冲突。
- 时间戳(Timestamp):在数据记录中加入时间戳字段,每次更新时通过比较时间戳判断是否发生冲突。
- CAS(Compare and Swap):使用原子操作的方式进行数据的更新,通过比较当前值和预期值是否一致来判断是否发生冲突。
乐观锁适合于读操作非常频繁,但写操作相对较少的场景,可以提高并发性能。然而,乐观锁需要保证数据不会被并发修改的假设成立,否则会引发数据不一致问题。如果冲突频率较高,乐观锁可能会引起大量的重试,降低性能。
在实际应用中,选择悲观锁还是乐观锁要根据具体的场景,考虑并发冲突的频率、数据一致性要求以及性能需求等因素。有时也可以结合两者的优点,使用适当的锁机制来满足需求。
总结:
锁的底层确实很复杂,我们也不是一篇两篇文章就可以讲清楚的,因此我写这篇文章更多的还是为了吸引大家的兴趣,如果有兴趣了可以再去深入的了解一下各种锁。
如果我的内容对你有帮助,请点赞,评论,收藏。创作不易,大家的支持就是我坚持下去的动力!
免责声明:
① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。
② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341