在当今计算机科学领域中,多核处理器和多线程编程已经成为常态。虽然多线程在提高性能和资源利用率方面具有巨大潜力,但同时也引入了一系列潜在的问题。其中最为关键的是并发安全问题
。
本文将深入探讨 Java 中的锁机制,以解释并发安全问题的根本原因以及锁如何帮助我们解决这些问题。
首先,我们需要深入理解为何多线程会引发并发安全问题,而单线程操作通常不会。根本原因就在于并发性。
设想有一个不透明盒子,其中包含一个数字 5,同时有两个人 A 和 B 试图同时查看盒子内的数据并执行加 1 的操作,然后将计算结果写回盒子中。然而,我们会发现每个人的计算结果都是 6,而不是 7。这是一个典型的并发问题,因为它们都在看到数字 5 后,在各自的大脑中存储了数值 5,并执行了加 1 操作,因此最终的结果是错误的。
解决这个问题的方法有以下两种。
上述两种方案在 Java 中通常对应于经典的阻塞锁
与 CAS 自旋锁
。
由上面的例子,我们基本可以了解产生多线程并发问题的根本原因。
不透明盒子
,其中一个人修改了数据之后,另外一个人并不知道修改了什么,所以导致的计算结果的错误。这三个问题,是最基本的线程安全产生的原因。
我们通过上面的案例以及学习的产生多线程并发问题的根本原因可以得知,如果我们能够将竞态条件
、可见性问题
、临界区问题
给解决掉,那么我们就能够对并发安全问题手拿把掐,后续我们将从两种手段
来解决上述的三种问题。
我们上文学习到,并发安全问题是因为竞态条件下没有限制地修改临界区所产生的,那么假设我们将临界区变为不可修改,是不是就可以从根本上解决线程安全的问题?既然修改会产生问题,那么我就不让你修改!
在 Java 中,final
修饰的属性(字段或变量)可以帮助确保线程安全,因为 final
具有以下特性:
final
属性一旦被赋予初值,就不能再被修改。这意味着其他线程无法更改这个属性的值。在多线程环境下,如果多个线程可以同时修改同一个变量,那么就会出现竞态条件和并发冲突,从而导致线程不安全。因为final
属性的不可变性,所以能够彻底消除这种风险。总的来说,并发安全问题是因为修改产生的,那么我改都不让你改,你如何能够出现线程安全问题?
在前文中,我们已经了解到 final
修饰的方式适用于那些属性在对象创建后不需要修改的特定场景。然而,在实际应用中,许多情况下需要多线程对临界区数据进行修改。因此,我们将重点介绍第二种确保线程安全的方式,即使用线程锁。
为了保证线程安全,在 Java 中,JDK 官方提供了多种线程锁机制,涵盖了从原生的 synchronized
关键字到 Lock
接口的具体实现,包括 ReentrantLock
、ReentrantReadWriteLock
等。这些机制能够有效地管理多线程对共享资源的访问,确保在任何时刻只有一个线程可以执行临界区代码,从而避免潜在的并发问题。
synchronized
和 Lock
虽然都用于多线程编程中的同步,但它们并不是相互替代的关系,而是各自有其适用的情境和特点。特别是在 JDK 1.6 之后,随着引入轻量级锁和偏向锁,synchronized
的性能得到了显著提升。在一些对并发要求不高、需求不太复杂的场景中,synchronized
实际上并不比 Lock
差,反而可能是更好的选择。
这是因为在这些场景下,synchronized
更加简单直观。它隐含了锁的获取和释放过程,无需用户过多关心锁的释放问题,大大减少了代码的复杂性。对于不太复杂的开发需求,使用 synchronized
更容易理解和维护。
在一些简单的应用场景下,synchronized
的性能和易用性使其成为更好的选择。
在本章节中,我们将对synchronized
关键字做一个具体的学习,在synchronized
锁的分类中,我们大致可以将锁分为以下两种:
实现 | 锁分类 | 特性 |
---|---|---|
synchronized 关键字 | 类锁 | 独占、可重入、作用于整个类,影响类的所有实例 |
实例锁 | 独占、可重入、作用于实例,只影响同一个实例的线程访问 |
不过,在介绍 synchronized 关键字之前,我们需要先明晰独占特性
和可重入特性
的概念。
什么是独占特性?
独占锁是一种锁的模式,它在任意时刻只能被一个线程持有。当一个线程获得了独占锁,其他线程就无法同时获得相同的锁,它们必须等待当前持有锁的线程释放锁后才能获取。
独占锁的主要特点包括:
- 排他性:一次只能有一个线程持有独占锁,其他线程必须等待。
- 互斥性:如果一个线程持有独占锁,其他线程试图获取锁时会被阻塞,直到锁被释放。
什么是可重入特性?
可重入特性指的是同一个线程在持有锁的情况下,能够再次获取该锁,而不会发生死锁。这使得同一个线程可以多次进入由同一把锁保护的临界区域,而不会被阻塞。
可重入性的主要特点包括:
- 同一线程可多次获取锁: 如果一个线程已经获得了某个锁,那么在持有该锁的情况下,它可以再次获取相同的锁。
- 防止死锁: 可重入性避免了因为同一线程在持有锁的情况下无法再次获取锁而导致的死锁情况。
可重入性在编写复杂的程序时非常有用,尤其是当一个方法调用另一个加锁的方法时。如果锁不支持可重入性,这样的调用可能会导致死锁,因为同一线程在调用的过程中无法再次获取已经持有的锁。可重入特性可以保证同一个线程在同步块或锁的保护下多次调用被同一把锁保护的方法,而不会发生死锁。
了解了独占特性和可重入特性之后,我们下面将正式进入 synchronized
关键字的学习中去。
synchronized 是 Java 中的一个关键字,具有独占性
、互斥性
和可重入性
。在 Java 中,它的作用范围可以是方法体声明或者代码块。
类锁是全局唯一的,这主要是因为在 Java 中,每个类都对应一个 Class
对象,而这个 Class
对象在整个 JVM 中是唯一的。当使用 synchronized (ClassName.class)
时,锁定的就是这个唯一的 Class
对象。
下面我们将演示方法签名声明
和代码块声明
两种类锁的加锁方式。
方法签名声明锁
public class SynchronizedClassTest {
public static void main(String[] args) throws InterruptedException {
Task task = new Task();
Thread thread1 = new Thread(task, "线程1");
Thread thread2 = new Thread(task, "线程2");
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("最终的计算结果"+task.getCount());
}
private static class Task implements Runnable {
static int count = 0;
@Override
public void run() {
Task.addCount();
}
public static synchronized void addCount(){
for (int j = 0; j < 100000; j++) {
count++;
}
}
public int getCount() {
return count;
}
}
}
在上面的代码中,我们在静态方法的声明中直接声明了 synchronized,当静态方法直接声明 synchronized 的时候,JVM 会自动使用类锁的加锁方式。我们可以使用一个示意图来描述这个过程:
我们可以近似地理解为,线程是从 SynchronizedClassTest.class 这把锁里面进入后,再进行的累加操作,谁抢占到了这个锁谁去加,抢不到就等人家释放了再抢。
代码块声明锁
在方法签名声明的synchronized
可能会带来更大的性能开销,因为进入和退出方法都需要进行锁的获取和释放操作。而在代码块级别,可以更灵活地控制锁的获取和释放时机,从而减小锁的开销。
我们假设存在 5 个线程执行任务,当关键字声明在方法签名上时,5 个线程的执行如下:
我们可以从图中看到,即使查询数据库
和其他逻辑
并不会出现线程安全问题,但是因为将 synchronized 声明在了方法签名上,导致无论谁来都只能等待上一个线程将任务执行完毕!
但是,如果我们只锁定会出现并发安全的逻辑,比如上图的修改临界区
那一段逻辑就能大大地加快运行效率,比如下图:
使用代码块声明锁可以选择性地锁定方法内的一部分代码,从而缩小锁的粒度。这样其他线程就有更大的机会在不需要锁的部分执行,提高了并发性,它的具体使用如下:
public class SynchronizedClassTest {
public static void main(String[] args) throws InterruptedException {
Task task = new Task();
Thread thread1 = new Thread(task, "线程1");
Thread thread2 = new Thread(task, "线程2");
long currentTimeMillis = System.currentTimeMillis();
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("总耗时:" + (System.currentTimeMillis() - currentTimeMillis));
}
private static class Task implements Runnable {
@Override
public void run() {
try {
Task.simulate();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void simulate() throws InterruptedException {
System.out.println(Thread.currentThread().getName() + ": 开始查询数据库");
//模拟耗时
Thread.sleep(5000);
System.out.println(Thread.currentThread().getName() + ": 查询数据库结束");
synchronized (SynchronizedClassTest.class) {
System.out.println(Thread.currentThread().getName() + ": 开始修改临界区数据");
//修改临界区数据操作 模拟耗时
Thread.sleep(2000);
System.out.println(Thread.currentThread().getName() + ": 修改临界区数据结束");
}
System.out.println(Thread.currentThread().getName() + ": 开始执行剩余的逻辑");
Thread.sleep(3000);
System.out.println(Thread.currentThread().getName() + ": 执行剩余的逻辑成功");
}
}
}
上述代码大约只需要 10 秒左右就可以执行完毕,在相同的环境下,采用方法声明 synchronized 的方式下需要 20 秒左右。
总体来说,选择方法级别或代码块级别的synchronized
应该根据具体的需求和场景。如果整个方法都需要被同步,那么方法级别的synchronized
可能更方便。如果只有方法内的一小部分代码需要同步,而其他部分可以并发执行,那么代码块级别的synchronized
可能更适合。
类锁的加锁方式是全局唯一的,也就是整个 JVM 只有这一把锁,就如我们上文提到的,锁的粒度太大了。如果我们能够把 class 对象实例化的对象作为锁对象,那么粒度就小了很多,因为一个 class 对象可以实例化无数个对象,这就是实例锁。
相比类锁,实例锁有以下的好处:
与类锁相同的是,实例锁也有方法声明和代码块加锁两种加锁方式,只要方法不是静态方法,那么将synchronized
声明到方法签名中它自动就会使用当前实例作为实例锁,这里只演示代码块加锁的方式。
使用当前实例作为锁对象
使用当前实例作为锁对象,只需要使用 this 当作synchronized
关键字的参数就可以了:
public class SynchronizedCodeTest {
public static void main(String[] args) throws InterruptedException {
Task task = new Task();
Thread thread1 = new Thread(task, "线程1" );
Thread thread2 = new Thread(task, "线程2" );
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println( "最终的计算结果" +task.getCount());
}
private static class Task implements Runnable {
int count = 0;
@Override
public void run() {
for (int j = 0; j < 100000; j++) {
synchronized (this) {
count++;
}
}
}
public int getCount() {
return count;
}
}
}
使用其他对象作为锁对象
使用其他对象作为锁对象需要先创建一个对象,然后将该对象传递到synchronized
关键字中,不同的synchronized
关键字使用同一个锁对象代表这些synchronized
使用的是同一把锁:
public class SynchronizedCodeTestOther {
public static void main(String[] args) throws InterruptedException {
Object lock = new Object();
Task task = new Task(lock);
Thread thread1 = new Thread(task, "线程1" );
Thread thread2 = new Thread(task, "线程2" );
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println( "最终的计算结果" +task.getCount());
}
private static class Task implements Runnable {
private final Object lock;
int count = 0;
private Task(Object lock) {
this.lock = lock;
}
@Override
public void run() {
for (int j = 0; j < 100000; j++) {
synchronized (lock) {
count++;
}
}
}
public int getCount() {
return count;
}
}
}
上述的两种方式是在开发中最常用的两种方式,面对简单的并发场景下不用考虑太复杂的加锁逻辑,直接使用 synchronized 就可以了。对于性能而言,JDK 1.6 优化之后,synchronized 的性能也还不错,所以,简单的并发优先推荐这种方式!
至此,相信你已经理解了 synchronized 的加锁原理,并且也可以用来应付后续的面试!不过,有关于 synchronized 的原理网上一抓一大把,这里不做太多过于细节的讲解,因为这对于大家来说反而会增大理解的难度。
我们上文说过,JDK 1.6 之后,对于 synchronized 做了很多优化,其中的重点就是锁的升级过程。在使用 synchronized 加锁的时候,Java 并不会直接调用操作系统内核加锁,而是根据线程的竞争情况采用不同的策略逐渐升级锁,直至调用操作系统加锁。
锁的升级包含以下几个过程:
整体 synchronized 的锁升级过程为:偏向锁 -> 轻量级锁(自旋锁) -> 重量级锁。
为了帮助你理解这个过程,首先我们需要理解一个对象在存储空间的对象结构:
有关锁的信息被存储在了 MarkWord 中,这里以 64 位虚拟机为例,在不开启压缩的情况下,MarkWord 占用 64 位空间用于存储数据,具体存储如下:
锁状态 | 25 bit | 31 bit | 1 bit | 4 bit | 1 bit | 2 bit |
---|---|---|---|---|---|---|
空闲空间 | 分代年龄 | 偏向锁 | 锁标志位 | |||
无锁 | 空闲 | hashCode值 | 0 | 01 | ||
偏向锁 | 线程id(54 bit)– + –偏向时间戳Epoch(2 bit) | 1 | 01 | |||
轻量级锁 | ptr_to_lock_record(62 bit) 栈中锁记录指针 | 00 | ||||
重量级锁 | ptr_to_heavyweight_monitor(62 bit) 互斥量指针 | 10 |
01
,此时不存在线程执行任务。我们可以近似地理解,偏向锁和轻量级锁都是系统通过 CAS 修改对象头中的锁标记位来实现的,只有重量级锁才会调用操作系统内核进行加锁或者入队操作。一个是只需要修改点东西就能实现,一个是需要入队、阻塞、唤醒、出队等诸多步骤才能实现,谁快谁慢不言而喻!
以上就是 JDK 对于 synchronized 锁的优化,重量级锁相对而言太慢了,所以 JDK 官方才会采用一系列的动作借此完成对于锁的优化。
在本章节中,我们深入探讨了 Java 中synchronized
关键字的具体应用及其深层次的细节。
首先,我们详细介绍了synchronized
的两种主要应用场景:类锁和实例锁。通过具体实例和案例分析,我们深入了解了在不同情境下选择合适的锁定方式的优缺点,有助于在实际应用中做出明智的选择。
随后,我们深入研究了 JDK 1.6 之后对synchronized
的优化措施。这一部分内容包括了具体的优化实现和背后的原理,让你对 Java 虚拟机在提升synchronized
性能方面的工作有了更深入的理解。我们强调了这些优化如何提高多线程程序的效率,并在实际项目中发挥积极作用。
特别地,我们详细描述了synchronized
锁的升级过程,并解释了升级发生的原因。通过分析锁升级的各个阶段,你能够理解在并发编程中锁的状态如何随着程序执行而动态演变,从而更好地优化代码以避免潜在的性能瓶颈。
通过这章节的学习,相信你已经获得了对于synchronized
更为深入、全面的认识,能够更自信、更有效地应对多线程编程中的挑战。在下一章节中,我们将对 Java 锁中最常用的 Lock 接口做一个详细的讲解和分析。
© 2019 - 2023 Liangliang Lee. Powered by gin and hexo-theme-book.