目录
十七 . 并发相关基础概念
17.1 线程安全
17.2 保证线程安全的两个方法
17.2.1 封装
17.2.2 不可变
17.2.2.1 final 和 immutable解释
17.3 线程安全的基本特性
17.3.1 原子性(Atomicity)
17.3.2 可见性(Visibility)
17.3.2.1 volatile 关键字
17.3.2.2 synchronized 关键字
17.3.2.3 Lock 接口
17.3.2.3.1 解释Lock接口:
17.3.3 有序性
17.3.3.1 volatile 关键字
17.3.3.2 synchronized 关键字
17.3.3.3 Lock 接口
17.3.3.4 happens-before 原则
17.3.3.4.1 线程中断规则(Thread Interruption Rule):
17.3.3.4.2 线程终止规则
17.3.4 互斥性
17.3.4.1 synchronized 关键字例子:
17.3.4.2 Lock 接口例子:
十八 . synchronized底层如何实现?什么是锁的升级,降级?
18.1 典型回答
18.1.1 monitorenter和monitorexit解释:
18.1.2 Monitor实现
18.1.3 锁的升级,降级
18.1.4 偏向锁
18.1.4.1 偏向锁的原理
18.1.4.2 偏向锁例子
18.1.4.2.1例子详细解释:
18.1.4.3 偏斜锁如何保证多线程环境下数据安全
18.1.4.4 可重入锁或非重入锁解释
18.1.4.4.1 可重入锁的例子:
18.1.4.5 互斥操作
18.1.4.6 偏向锁的优化点小结
18.1.5 轻量级锁
18.1.6 重量级锁
18.1.7 轻量级锁和重量级锁的比较
18.1.8 Java是否会进行锁的降级?
18.1.9 临界区域(Critical Section)解释
十七 . 并发相关基础概念
可能前面几讲,一些同学理解可以有一些困难,这一篇将进行一些并发相关概念比较模糊,我们将进行并发相关概念的补充,
17.1 线程安全
线程安全就是在多线程的环境下正确的一个概念,保证在多线程的环境下是实现共享的,可修改的状态是正确性,状态可以类比为程序里面的数据。
如果状态不是共享的,或者不是可修改的,就不存在线程安全的问题。
17.2 保证线程安全的两个方法
17.2.1 封装
进行封装,我们将对象内部的状态隐藏,保护起来。
17.2.2 不可变
可以进行final和immutable进行设置。
17.2.2.1 final 和 immutable解释
final
和 immutable
是 Java 中用来描述对象特性的关键字。
-
final
:用于修饰变量、方法和类。它的作用如下:- 变量:
final
修饰的变量表示该变量是一个常量,不可再被修改。一旦赋值后,其值不能被改变。通常用大写字母表示常量,并在声明时进行初始化。 - 方法:
final
修饰的方法表示该方法不能被子类重写(覆盖)。 - 类:
final
修饰的类表示该类不能被继承。
- 变量:
-
immutable
:指的是对象一旦创建后,其状态(数据)不能被修改。不可变对象在创建后不可更改,任何操作都不会改变原始对象的值,而是返回一个新的对象。
不可变对象的主要特点包括:
- 对象创建后,其状态无法更改。
- 所有字段都是
final
和私有的,不可直接访问和修改。 - 不提供可以修改对象状态的公共方法。
不可变对象的优点包括:
- 线程安全:由于对象状态不可更改,因此多线程环境下不需要额外的同步措施。
- 缓存友好:不可变对象的哈希值不会改变,因此可以在哈希表等数据结构中获得更好的性能。
17.3 线程安全的基本特性
17.3.1 原子性(Atomicity)
指的是一系列操作要么全部执行成功,要么全部失败回滚。即一个操作在执行过程中不会被其他线程打断,保证了操作的完整性。
17.3.2 可见性(Visibility)
指的是当一个线程修改了共享变量的值后,其他线程能够立即看到最新的值。需要通过使用 volatile
关键字、synchronized
关键字、Lock
接口等机制来确保可见性。
详细解释:
17.3.2.1 volatile
关键字
当一个变量被声明为volatile时,任何对该变量的修改都会立即被其他线程可见。
当写线程将flag值修改为true后,读线程会立即看到最新的值,并进行相应的操作。这是因为flag变量被声明为volatile,确保了可见性。
public class VisibilityExample {
private volatile boolean flag = false;
public void writerThread() {
flag = true; // 修改共享变量的值
}
public void readerThread() {
while (!flag) {
// 循环等待直到可见性满足条件
}
System.out.println("Flag is now true");
}
}
17.3.2.2 synchronized
关键字
两个方法都使用synchronized关键字修饰,确保了对flag变量的原子性操作和可见性。当写线程修改flag的值为true后,读线程能够立即看到最新的值。
public class VisibilityExample {
private boolean flag = false;
public synchronized void writerThread() {
flag = true; // 修改共享变量的值
}
public synchronized void readerThread() {
while (!flag) {
// 循环等待直到可见性满足条件
}
System.out.println("Flag is now true");
}
}
17.3.2.3 Lock
接口
通过使用ReentrantLock实现了显式的加锁和释放锁操作。当写线程获取锁并修改flag的值为true后,读线程也需要获取同样的锁才能看到最新的值。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class VisibilityExample {
private boolean flag = false;
private Lock lock = new ReentrantLock();
public void writerThread() {
lock.lock();
try {
flag = true; // 修改共享变量的值
} finally {
lock.unlock();
}
}
public void readerThread() {
lock.lock();
try {
while (!flag) {
// 循环等待直到可见性满足条件
}
System.out.println("Flag is now true");
} finally {
lock.unlock();
}
}
}
17.3.2.3.1 解释Lock接口:
使用Lock接口进行同步时,通过持有锁可以确保在临界区内的操作是互斥的,即同一时间只能有一个线程执行临界区的代码。这样可以避免多个线程同时对共享变量进行修改带来的问题。
当读线程在访问共享变量之前,发现变量的值不符合预期,即不满足可见性条件时,它会进入循环等待的状态。这样做的目的是等待写线程将最新的值写回共享变量,并使其对其他线程可见。
循环等待的方式可以有效地解决可见性问题。当写线程修改共享变量的值后,它会释放锁。此时,读线程能够重新获取锁并再次检查共享变量的值。如果值已经满足可见性条件,读线程就能够继续执行后续的操作。
需要注意的是,在循环等待的过程中,读线程应该使用适当的等待方式,例如Thread.sleep()或者Lock接口提供的Condition条件对象的await()方法,以避免占用过多的CPU资源。
通过循环等待直到可见性满足条件,可以确保读线程在访问共享变量时能够看到最新的值,从而实现了可见性的要求。
17.3.3 有序性
指的是程序执行的顺序与预期的顺序一致,不会受到指令重排序等因素的影响。可以通过 volatile
关键字、synchronized
关键字、Lock
接口、happens-before
原则等来保证有序性。
例子:
17.3.3.1 volatile
关键字
使用volatile关键字修饰counter变量,确保了对变量的读写操作具有可见性和有序性。其他线程能够立即看到最新的值,并且操作的顺序不会被重排序。
public class OrderingExample {
private volatile int counter = 0;
public void increment() {
counter++; // 非原子操作,但通过volatile关键字确保了可见性和有序性
}
public int getCounter() {
return counter; // 获取变量的值
}
}
17.3.3.2 synchronized
关键字
使用synchronized关键字修饰了increment()和getCounter()方法,确保了对counter变量的原子操作,同时也提供了可见性和有序性的保证。
public class OrderingExample {
private int counter = 0;
public synchronized void increment() {
counter++; // 原子操作,同时具备可见性和有序性
}
public synchronized int getCounter() {
return counter; // 获取变量的值
}
}
17.3.3.3 Lock
接口
通过使用Lock接口实现了显式的加锁和释放锁操作,确保了对counter变量的原子操作,同时也提供了可见性和有序性的保证。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class OrderingExample {
private int counter = 0;
private Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
counter++; // 原子操作,同时具备可见性和有序性
} finally {
lock.unlock();
}
}
public int getCounter() {
return counter; // 获取变量的值
}
}
17.3.3.4 happens-before
原则
happens-before是并发编程中的一个概念,用于描述事件之间的顺序关系。在多线程或多进程的环境中,经常会出现多个事件同时发生的情况,而它们之间的执行顺序可能是不确定的。为了确保程序正确地执行,我们需要定义一些规则来解决竞态条件和并发问题。
happens-before关系用于描述事件之间的顺序关系,并指定了一个事件在执行结果上的先于另一个事件。如果一个事件A happens-before 另一个事件B,那么我们可以说事件A在时间上 "早于" 事件B,而事件B在时间上 "晚于" 事件A。
根据Java内存模型(Java Memory Model,简称JMM)的规定。
happens-before关系例子:
- 程序顺序原则(Program Order Rule):在单个线程中,按照程序的顺序,前面的操作 happens-before 后面的操作。
- volatile变量规则(Volatile Variable Rule):对一个volatile域的写操作 happens-before 于后续对该域的读操作。volatile变量的写-读能够确保可见性。
- 传递性(Transitive):如果事件A happens-before 事件B,事件B happens-before 事件C,那么可以推导出事件A happens-before 事件C。通过传递性,可以推断出不同事件之间的happens-before关系。
- 线程启动规则(Thread Start Rule):Thread对象的start()方法调用 happens-before 新线程的所有操作。
- 线程终止规则(Thread Termination Rule):线程的所有操作 happens-before 其他线程中对该线程终止检测的操作。
- 线程中断规则(Thread Interruption Rule):对线程的interrupt()方法的调用 happens-before 所被中断线程中的代码检测到中断事件的发生。
例子:
17.3.3.4.1 线程中断规则(Thread Interruption Rule):
线程A会执行一段任务。在线程A的任务执行的过程中,会循环检查中断状态,当线程B调用线程A的interrupt()
方法进行中断时,线程A会在检查中断状态的代码处发现自己已被中断并返回。这里,线程B的interrupt()
调用和线程A的检查中断状态的操作之间存在一个happens-before关系,保证线程B中的中断操作能被线程A正确检测到。
class MyTask implements Runnable {
@Override
public void run() {
// 执行任务的代码
// ...
// 检查中断状态
if (Thread.interrupted()) {
// 在此处被中断
return;
}
// 继续执行任务的代码
// ...
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread threadA = new Thread(new MyTask());
threadA.start();
// 主线程等待一段时间后中断线程A
Thread.sleep(1000);
threadA.interrupt();
}
}
17.3.3.4.2 线程终止规则
主线程首先创建一个子线程,并将isRunning
设置为true
,然后子线程进入一个死循环,并在每次循环中检查isRunning
的值。主线程等待2秒后,将isRunning
设置为false
,终止子线程的执行,并使用join()
方法等待子线程终止。最后,主线程打印出"主线程继续执行"。
子线程的终止操作isRunning = false
happens-before 主线程中对isRunning
的读取操作,因此主线程能够观察到子线程的终止,并能够继续执行。这符合线程终止规则。
public class ThreadTerminationExample {
private static volatile boolean isRunning = true;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
while (isRunning) {
// 线程执行的工作...
}
System.out.println("线程已终止");
});
thread.start();
Thread.sleep(2000);
isRunning = false; // 终止线程
thread.join(); // 等待线程终止
System.out.println("主线程继续执行");
}
}
happens-before关系的定义保证了程序执行的可见性和有序性,为并发编程提供了一定的保证。开发人员可以利用这些规则来避免竞态条件和并发问题。
17.3.4 互斥性
指的是同一时间只允许一个线程对共享资源进行操作,其他线程必须等待。可以通过使用 synchronized
关键字、Lock
接口来实现互斥性。
17.3.4.1 synchronized
关键字例子:
使用synchronized关键字修饰了increment()和getCount()方法,这意味着同一时间只能有一个线程访问这两个方法。当一个线程在执行increment()方法时,其他线程需要等待,直到当前线程执行完毕才能继续访问。这样可以保证count的操作是原子的,避免了并发访问导致的数据冲突。
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
17.3.4.2 Lock
接口例子:
使用ReentrantLock来创建一个锁,并在increment()和getCount()方法中使用lock()方法获取锁,unlock()方法释放锁。这样同一时间只允许一个线程获取锁并执行代码块,其他线程需要等待锁被释放后才能继续执行,从而实现了互斥性。
无论是使用synchronized关键字还是Lock接口,它们都能够实现互斥性,保证多线程对共享资源的访问是同步的,避免了数据冲突和不一致的问题。但Lock接口相比synchronized关键字更加灵活,可以更精细地控制锁的获取和释放,提供了更多的功能。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
private int count = 0;
private Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
十八 . synchronized底层如何实现?什么是锁的升级,降级?
前面博客看了后,相信你对线程安全和如何使用基本的同步机制有了基础,接下了,进入synchronized底层机制。
18.1 典型回答
sychronized 代码块是由monitorenter/monitorexit指令实现的,Monitor对象是同步的基本实现单元。
18.1.1 monitorenter
和monitorexit
解释:
monitorenter
和monitorexit
是Java字节码指令,用于实现Java对象的同步锁机制。具体来说,monitorenter
指令用于获取对象的监视器锁,而monitorexit
指令用于释放对象的监视器锁。
在Java虚拟机中,每个对象都与一个监视器关联,可以使用synchronized
关键字或者Object
类的wait()
、notify()
和notifyAll()
方法来对对象的监视器进行操作。在字节码层面,monitorenter
和monitorexit
指令就是实现这些操作的。
18.1.2 Monitor实现
Java6前,Monitor的实现完全依靠操作系统内部的互斥锁,因为需要从用户态切换到内核态,同步操作是一个无差别的重量级操作。
现代的JDK中,JVM进行了很大的改进,提供了三种不同的Monitor实现:
如:偏斜锁(Biased Locking),轻量级锁,重量级锁,进行了性能的改进。
18.1.3 锁的升级,降级
JVM进行优化synchronized运行的机制,当JVM检测到不同的竞争状况时,会自动切换到适合的锁实现,这种切换就是锁的升级,降级。
当没有竞争出现时,默认使用偏斜锁,JVM会利用CAS操作在对象头上的Mark Word部分设置线程ID,以表示这个对象偏向于当前线程,所以并不涉及真正的互斥锁。
这样做的假设是基于很多应用场景中,大部分对象生命周期中最多会被一个线程锁定,使用偏斜锁可以降低无竞争开销。
18.1.4 偏向锁
当没有竞争出现时,使用偏斜锁可以提供更好的性能表现。
18.1.4.1 偏向锁的原理
- 当一个线程访问一个对象时,JVM会首先在对象的头部中的 Mark Word 字段记录当前线程的 ID,并将对象标记为“偏向锁”。
- 如果其他线程尝试获取该对象的锁时,会发现该对象已经被偏向于某个线程,此时它们会进行自旋等待,而不会立即阻塞。
- 如果其他线程一直自旋等待,而偏斜锁拥有者的线程也不断访问该对象(保持偏斜状态),JVM会消除偏斜锁,使得对象变为无锁状态。
- 如果其他线程成功获取了偏斜锁,或者偏斜锁拥有者的线程退出同步块,JVM会撤销偏斜锁的状态,将对象重新恢复为可重入锁或非重入锁。
18.1.4.2 偏向锁例子
-
一个多线程程序,其中有一个共享的计数器对象。在绝大多数情况下,只有一个线程会访问该计数器对象进行自增操作,其他线程很少去改变它。这时候使用偏斜锁会带来明显的性能优势。
- 初始状态:计数器对象未被任何线程访问,处于无锁状态。
- 线程 A 访问计数器对象:线程 A 首先将对象头部中的 Mark Word 设置为自己的线程 ID,并将对象标记为“偏向锁”。线程 A 自增计数器并完成操作。
- 其他线程尝试访问计数器对象:线程 B、C、D 等尝试获取计数器对象的锁,但发现该对象已经被偏向于线程 A。它们会进行自旋等待,但不会立即阻塞。
- 偏斜锁保持状态:线程 A 再次访问计数器对象,JVM会发现该对象已经是偏斜锁状态,并且访问线程和持有偏斜锁线程是同一个。因此,线程 A 可以直接访问对象,而不需要进行互斥操作。
- 竞争出现:如果线程 B 尝试获取计数器对象的锁,并且与偏斜锁拥有者不是同一个线程,那么偏斜锁就会被撤销,变为可重入锁或非重入锁,进而线程 B 可以成功获取锁。
通过偏斜锁的优化,当只有一个线程访问计数器对象时,不会产生真正的互斥操作,避免了线程切换和锁开销,提高了性能。只有在其他线程尝试获取锁时才会进行额外的操作,从而减少了无竞争的开销。这种设计基于大部分对象生命周期中只被一个线程访问的假设,并且可以适用于很多应用场景,提升程序的执行效率。
18.1.4.2.1例子详细解释:
当线程 A 再次访问计数器对象时,JVM会检查对象的偏斜锁状态和持有偏斜锁的线程是否与当前访问线程一致。如果是一致的,表示线程 A 仍然是该对象的主要访问者,JVM会直接允许线程 A 访问该对象,而无需进行任何互斥操作。这样可以避免线程切换和锁竞争,提高程序的执行效率。
然而,当其他线程(例如线程 B)尝试获取计数器对象的锁时,JVM会检测到存在竞争。竞争发生的条件是:尝试获取锁的线程与持有偏斜锁的线程不一致。在这种情况下,JVM会撤销偏斜锁的状态,将对象转换为可重入锁或非重入锁。
具体而言,线程 B 尝试获取计数器对象的锁时,JVM会在对象头部中更新 Mark Word 的信息:
- 将原先记录持有偏斜锁的线程 ID 清空,表示偏斜锁的状态被撤销。
- 将锁的状态标记为可重入锁或非重入锁。
此时,线程 B 成功获取了计数器对象的锁,并可以执行相应的操作。这个过程称为偏斜锁撤销,对象从偏斜锁状态转换为可重入锁或非重入锁状态。
这种竞争的出现使得原先持有偏斜锁的线程需要重新进行锁争用,而新的竞争线程能够成功获取锁。这种机制保证了当多个线程同时需要访问计数器对象时,能够按照先到先得的原则进行互斥操作,避免数据被错误修改。
总而言之,偏斜锁允许单线程对对象进行快速访问,提高了程序的执行效率。但当其他线程尝试获取锁时,偏斜锁会被撤销,以保证多线程环境下的数据安全性。
18.1.4.3 偏斜锁如何保证多线程环境下数据安全
假设有一个账户对象,包含账户余额信息。初始状态下,该账户对象处于无锁状态。
- 线程 A 获取偏斜锁:
- 线程 A 访问账户对象,JVM将对象头部中的 Mark Word 设置为自己的线程 ID,并将对象标记为“偏斜锁”,并且记录线程 A 是偏斜锁的拥有者。
- 线程 A 对账户余额进行修改操作,完成后释放锁。
此时,账户对象仍然是偏斜锁状态,访问线程和持有偏斜锁的线程是同一个线程(线程 A),因此线程 A 可以直接访问账户对象,而不需要进行互斥操作。
-
线程 B 尝试获取锁:
- 线程 B 也需要对账户对象进行修改操作,并尝试获取锁。
- JVM检测到线程 B 和持有偏斜锁的线程 A 不一致,表示存在竞争。
-
撤销偏斜锁:
- JVM会撤销账户对象的偏斜锁状态,将其转换为可重入锁或非重入锁。
- 对象头部的 Mark Word 会被更新,不再记录持有偏斜锁的线程 ID。
此时,线程 B 成功获取了账户对象的锁,并可以执行相应的操作。通过撤销偏斜锁,保证了在多个线程竞争下,只有一个线程能够持有锁并修改数据,避免了数据的错误修改和不一致性。
偏斜锁允许单线程(线程 A)对账户对象进行快速访问,提高了程序的执行效率。而当另一个线程(线程 B)尝试获取锁时,偏斜锁会被撤销,确保多线程环境下的数据安全性。这种机制保证了同一时间只有一个线程能够修改数据,避免了竞争条件和数据一致性问题的产生。
18.1.4.4 可重入锁或非重入锁解释
可重入锁(Reentrant Lock)是一种线程同步机制,也称为递归锁。它允许一个线程在持有锁的情况下再次请求获取同一个锁,而不会造成死锁。
当一个线程获取到可重入锁后,可以多次重复获取,而不会被自己所持有的锁所阻塞。这意味着线程可以进入由同一个锁保护的代码块,而不会对整个系统的状态造成死锁。
可重入锁通过维护一个持有计数器来实现。线程首次获取锁时,计数器加一;每次释放锁时,计数器减一。只有计数器为零时,锁才会完全释放,其他线程才能获取该锁。
相比之下,非重入锁(Non-Reentrant Lock)则不允许同一线程多次获取同一个锁。如果一个线程已经持有一个非重入锁,再次请求获取同一个锁时,会导致自己被阻塞,形成死锁。
18.1.4.4.1 可重入锁的例子:
有一个对象obj,它有两个方法method1和method2,其中method2需要在获取obj对象的锁后才能被调用。同时,我们希望在method1中调用method2,而不会导致死锁。
使用可重入锁可以很好地解决这个问题,
import java.util.concurrent.locks.ReentrantLock;
public class Example {
// 定义可重入锁
private ReentrantLock lock = new ReentrantLock();
public void method1() {
lock.lock(); // 获取锁
try {
// do something
method2(); // 调用method2
// do something
} finally {
lock.unlock(); // 释放锁
}
}
public void method2() {
lock.lock(); // 再次获取锁
try {
// do something
} finally {
lock.unlock(); // 释放锁
}
}
}
ReentrantLock类提供了可重入锁的实现。method1先获取锁,再通过调用method2获取同一个锁,并在最后释放锁。虽然method2也获取了锁,但由于是在同一个线程内部,因此不会发生死锁。
相反,如果使用非重入锁,则会在第二次尝试获取锁时产生死锁问题。
18.1.4.5 互斥操作
互斥操作指的是一种通过对共享资源的访问进行限制,以确保在同一时间内只有一个线程可以对该资源进行操作的机制。也就是说,当一个线程获得了对某个资源的访问权时,其他线程必须等待该线程释放资源后才能继续执行。
互斥操作的目的是避免多个线程同时对共享资源进行修改导致数据不一致或竞争条件的发生。在多线程环境下,如果没有互斥操作,多个线程可能同时读取或修改共享资源的值,从而引发意料之外的错误和不一致性。
常见的互斥操作包括使用互斥锁(Mutex)或信号量(Semaphore)。互斥锁是一种排他锁,它只允许一个线程在特定时刻获得锁资源,其他线程需要等待。当一个线程完成对共享资源的操作后,再释放锁,其他线程才能获得锁并继续操作。
互斥操作的实现通常依赖于底层的操作系统提供的原子操作、临界区或其他同步机制。这样可以保证在并发环境中,多个线程无法同时对关键资源进行操作,确保了数据的一致性和线程的安全性。
互斥操作是一种通过限制并发访问共享资源来确保数据的一致性和线程安全的机制。它能够有效避免多个线程对共享资源的竞争和冲突,提升多线程程序的正确性和可靠性。
18.1.4.6 偏向锁的优化点小结
偏斜锁的初衷是针对只有一个线程频繁访问同步块的场景而设计的,偏斜锁允许该线程连续地获得锁,而不需要进行互斥操作。这种连续获取锁的过程不会引起竞争和冲突,所以不需要额外的互斥操作。
通过偏斜锁机制,JVM可以避免频繁地进入和退出同步块所带来的性能损失。当只有一个线程在访问同步块时,JVM会将该对象的锁状态设置为偏斜锁,并将持有偏斜锁的线程ID记录下来。之后,该线程再次访问该对象时,会直接允许访问,而无需进行互斥操作。
需要注意的是,当其他线程尝试获取被偏斜锁占用的对象锁时,偏斜锁会自动升级为轻量级锁或重量级锁,从而引入互斥操作,以保证线程安全。
当一个线程再次访问持有偏斜锁的对象时,JVM会直接允许访问,因为此时并没有其他线程与之竞争。这种情况下不需要互斥操作,可以提升性能和效率。
18.1.5 轻量级锁
轻量级锁(Lightweight Locking)是Java虚拟机(JVM)中一种用于实现线程同步的机制,旨在提高多线程并发性能。
当一个线程尝试获取一个对象的锁时,JVM会将对象的锁状态切换为轻量级锁状态。轻量级锁的核心思想是尝试使用CAS(Compare and Swap)操作对对象头中的Mark Word进行加锁。以下是轻量级锁的具体解释:
-
初始状态:对象的锁状态为无锁状态(Unlocked),对象头中的Mark Word存储了一些额外的信息,比如指向当前线程栈中锁记录(Lock Record)的指针。
-
加锁操作:当一个线程希望获取该对象的锁时,它会尝试使用CAS操作将对象头的Mark Word设置为自己的线程ID,表示该线程获取到了锁。这个CAS操作是为了确保只有一个线程能够成功修改Mark Word。
-
CAS操作成功:如果CAS操作成功,表示当前线程成功获取到了对象的轻量级锁。此时,线程可以继续执行临界区代码,不需要进一步同步操作。
-
CAS操作失败:如果CAS操作失败,表示有其他线程竞争同一个锁。这时候,当前线程会尝试自旋(Spin)来等待锁的释放。自旋是一种忙等待的策略,线程会反复检查对象头的Mark Word是否变为无锁状态。
-
自旋失败:如果自旋超过了一定的次数或者达到了阈值,表示自旋失败。这时,JVM会将对象的锁状态升级为重量级锁(Heavyweight Lock)。升级为重量级锁涉及到线程阻塞和内核态的线程切换,比较耗费系统资源。
通过使用轻量级锁,JVM避免了无竞争情况下的阻塞与唤醒,并减少了系统资源的消耗。只有在出现竞争的情况下才需要进行降级为重量级锁,以保证线程安全性。
轻量级锁的具体实现和行为可能因不同的JVM版本和配置而有所差异。此外,轻量级锁只适用于短期的同步,对于长时间持有锁的情况,JVM仍会将其升级为重量级锁以避免资源浪费。
18.1.6 重量级锁
当一个线程获取到对象的轻量级锁后,如果它需要长时间持有该锁(比如执行时间较长的临界区代码),JVM会将其升级为重量级锁。这是因为长时间持有锁可能会导致其他线程长时间等待,造成资源浪费。
理解这一点可以从以下几个方面考虑:
-
自旋消耗资源:轻量级锁使用自旋来等待锁的释放,自旋是一种忙等待的策略,线程反复检查对象头的Mark Word是否变为无锁状态。如果持有锁的线程长时间不释放锁,那么其他线程会不断自旋等待,这会导致CPU资源的浪费。
-
防止饥饿现象:在长时间持有锁的情况下,其他线程将无法获得锁,这可能导致其他线程长时间等待,甚至发生饥饿现象。为了避免这种情况,JVM会将轻量级锁升级为重量级锁,使用阻塞等待的方式,确保其他线程能够公平地获得锁的机会。
-
重量级锁提供更强的互斥性:重量级锁使用操作系统提供的底层机制(如互斥量、信号量等)来实现线程同步,确保只有一个线程能够获取到锁。相比之下,轻量级锁仅使用CAS操作进行加锁,无法提供像操作系统级互斥那样的严格互斥性。对于长时间持有锁的情况,为了避免竞争和数据不一致的问题,JVM会将其升级为重量级锁。
轻量级锁适用于短期的同步,对于长时间持有锁的情况,JVM会将其升级为重量级锁以避免资源浪费和提供更强的互斥性,保证线程之间的公平竞争和顺畅执行。
18.1.7 轻量级锁和重量级锁的比较
轻量级锁和重量级锁都是用于实现线程同步的机制,但它们在性能和实现方式上存在差异。
在轻量级锁中,当一个线程获取到锁时,它会将对象头中的Mark Word修改为指向自己线程栈中锁记录的指针,并使用CAS操作进行加锁。这种方式避免了线程阻塞和内核态的线程切换,对于短期持有锁的情况下具有较好的性能表现。
然而,当一个线程需要长时间持有锁时,也就是执行时间较长的临界区代码时,其他线程可能会长时间等待锁的释放,进而导致饥饿现象的发生。这是因为其他线程持续自旋等待锁的释放,而得不到执行的机会。
为了避免饥饿现象和资源浪费,JVM会将轻量级锁升级为重量级锁。重量级锁是使用操作系统提供的底层机制(如互斥量、信号量等)实现的,通过阻塞等待的方式,确保其他线程能够公平地获得锁的机会。当一个线程持有重量级锁时,其他线程将被阻塞,不会再执行自旋等待,从而避免了饥饿现象的发生。
重量级锁的实现方式可能涉及到线程的阻塞与唤醒、操作系统的内核态切换等,因此会比轻量级锁产生更多的开销。所以,在长时间持有锁的情况下,使用重量级锁可以确保其他线程能够公平竞争锁的机会,但也会导致一定的性能损失。
轻量级锁适用于短期持有锁的情况,对于长时间持有锁的情况,为了避免饥饿现象和资源浪费,JVM会将轻量级锁升级为重量级锁,使用阻塞等待的方式来保证公平竞争。重量级锁虽然确保了公平性,但会带来一定的性能损失。
18.1.8 Java是否会进行锁的降级?
Java 中,锁的升级是指从轻量级锁升级为重量级锁的过程,而锁的降级则指从重量级锁降级为轻量级锁或无锁状态。
Java 并没有提供直接的锁降级机制。一旦锁升级为重量级锁,就不会再自动降级为轻量级锁或无锁状态。
这是因为重量级锁是通过操作系统提供的底层机制实现的,与 Java 对象头中的标记字段无关。
只有当持有重量级锁的线程释放锁后,其他线程才能获取锁,不会再回到轻量级锁或无锁状态。
然而,在某些特定的情况下,我们可以手动进行锁的降级操作。
比如:
如果一个线程在执行临界区代码时,发现临界区的代码执行时间很短,那么它可以选择将重量级锁降级为轻量级锁或无锁状态,以减少性能开销。具体的做法是,线程在临界区代码执行完毕后,将对象头中的标记字段修改为指向自己线程栈中的锁记录,进而实现锁的降级。
需要注意的是,锁的降级需要程序员手动控制和管理,必须保证在临界区代码执行期间没有其他线程竞争同一个锁。否则,降级操作可能会导致数据不一致或并发问题。
Java 并没有内置的锁降级机制,一旦锁升级为重量级锁,就无法自动降级为轻量级锁或无锁状态。但在特定情况下,可以手动进行锁的降级操作,以减少性能开销。但需要注意保证降级操作的正确性和线程安全性。
例子:
共享资源 counter
表示计数器,多个线程需要并发地对其进行操作。我们使用一个重量级锁来保护这个计数器,初始状态下所有线程都无法获取这个锁。
class Counter {
private int count;
private final Object lock = new Object();
public void increment() {
synchronized (lock) {
// 进入临界区域
count++;
// 临界区域代码执行完毕,可以尝试锁降级
// 将锁降级为轻量级锁或无锁状态
// 需要手动修改对象头中的标记字段
lock.notifyAll(); // 唤醒等待该锁的线程
}
}
public int getCount() {
return count;
}
}
使用了一个 synchronized
同步块来实现重量级锁,其中对 counter
进行了自增操作,并通过 lock.notifyAll()
来唤醒其他等待该锁的线程。
现在,假设线程 A 获取到了锁,并执行 increment
方法,对 count
自增完毕后,它选择将锁降级为轻量级锁或无锁状态:
public class Main {
public static void main(String[] args) {
Counter counter = new Counter();
// 线程 A
Thread threadA = new Thread(() -> {
synchronized (counter.lock) {
// 进入临界区域
counter.increment();
// 临界区域代码执行完毕,可以尝试锁降级
// 将锁降级为轻量级锁或无锁状态
// 需要手动修改对象头中的标记字段
// 假设此时没有其他线程竞争同一个锁
counter.lock.notifyAll(); // 唤醒等待该锁的线程
}
});
// 线程 B
Thread threadB = new Thread(() -> {
synchronized (counter.lock) {
try {
counter.lock.wait(); // 等待线程 A 完成临界区域代码
} catch (InterruptedException e) {
e.printStackTrace();
}
// 执行其他操作
}
});
threadA.start();
threadB.start();
try {
threadA.join();
threadB.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Count: " + counter.getCount());
}
}
线程 A 获取到了锁,并执行 increment
方法后,它选择将对象头中的标记字段修改为指向自己线程栈中的锁记录(这里是 counter.lock
)。然后调用 lock.notifyAll()
唤醒其他等待该锁的线程。
而线程 B 在获取到锁之后,调用 lock.wait()
进入等待状态,等待线程 A 执行完临界区域代码并唤醒它。
线程 A 将锁降级后,线程 B 能够在没有竞争的情况下获取到锁进行后续操作。
需要注意的是,锁的降级操作必须保证在临界区域代码执行期间没有其他线程竞争同一个锁,否则可能会导致数据不一致或并发问题。
实际应用中需要仔细考虑锁的升级和降级策略,并确保线程安全性。
18.1.9 临界区域(Critical Section)解释
指一段代码,其中涉及对共享资源的访问或操作。在多线程编程中,当多个线程并发地访问共享资源时,为了保证数据的一致性和正确性,需要将对共享资源的访问限制在临界区域内。
临界区域代码是指用于对共享资源进行访问或操作的代码片段。它是一个被保护起来的区域,同一时刻只能有一个线程进入并执行其中的代码。其他线程需要等待当前线程执行完毕并退出临界区域后才能进入。
临界区域的目的是确保多个线程不会同时对共享资源进行写操作,避免出现数据竞争和不一致的情况。通过限制对临界区域的互斥访问,可以保证在同一时间只有一个线程在执行对共享资源的操作,从而维护数据的有效性。
例子代码中,count++
的操作就是一个临界区域代码。在 increment
方法中,使用 synchronized
关键字将这段代码标记为临界区域,以保证同一时间只有一个线程可以执行该操作。其他线程在执行此段代码之前会被阻塞,直到当前线程执行完毕并释放锁后才能继续执行。文章来源:https://www.toymoban.com/news/detail-675275.html
所以,临界区域代码指的是多线程并发访问共享资源时需要保护的、只允许一个线程进入执行的代码片段。它起到了保护共享资源的作用,确保并发操作的正确性和数据的一致性。文章来源地址https://www.toymoban.com/news/detail-675275.html
到了这里,关于2023年Java核心技术面试第九篇(篇篇万字精讲)的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!