4.1 共享带来的问题
多线程下的i++和i--自增操作,会出现交错运行和导致结果不正确,例如:
临界区 Critical Section
只能由一个线程或进程执行,而其他线程或进程则被排除在外,不能同时执行该段代码。临界区的目的是保护共享资源,防止多个线程或进程同时对其进行访问而导致的数据竞争和不一致性。
特点和作用:
-
互斥性:在临界区中,同一时间只能有一个线程或进程执行,其他线程或进程必须等待。这确保了对共享资源的独占访问,避免了数据竞争。
-
原子性:临界区中的操作是原子性的,即不可分割的操作。这意味着在执行临界区中的代码时,不会被其他线程或进程中断,从而保证了操作的完整性。
-
同步性:临界区的使用可以实现线程或进程之间的同步,确保了对共享资源的有序访问。
实现方式:
-
互斥锁:使用互斥锁(Mutex)来保护临界区,通过对互斥锁进行加锁和解锁来控制临界区的访问。
-
信号量:使用信号量(Semaphore)来对临界区的访问进行控制,通过对信号量的 P(Wait)和 V(Signal)操作来实现同步。
-
条件变量:使用条件变量(Condition Variable)来等待临界区中的某些条件成立,从而控制线程的执行顺序。
-
自旋锁:在临界区被占用时,线程会一直处于忙等待状态,直到临界区被释放。
竞态条件 Race Condition
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件。
4.2 synchronized 解决方案
阻塞式的解决方案:synchronized,Lock
非阻塞式的解决方案:原子变量
synchronized 实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切 换所打断。
方法上的synchronized
class Test{
public synchronized void test() {
}
}
等价于
class Test{
public void test() {
synchronized(this) {
}
}
}
class Test{
public synchronized static void test() {
}
}
等价于
class Test{
public static void test() {
synchronized(Test.class) {
}
}
}
4.3 变量的线程安全分析
成员变量和静态变量:如果它们被共享了,但只有读操作,则线程安全。如果有读写操作,则需要考虑线程安全。
局部变量是线程安全的,但局部变量引用的对象则未必:
-
如果该对象没有逃离方法的作用访问,它是线程安全的。
-
如果该对象逃离方法的作用范围,需要考虑线程安全。(例如方法为public,被子类覆盖)
局部变量线程安全分析
public static void test1() {
int i = 10;
i++;
}
每个线程调用 test1() 方法时局部变量 i,会在每个线程的栈帧内存中被创建多份,因此不存在共享。
常见线程安全类:
-
String
-
Integer
-
StringBuffer
-
Random
-
Vector(线程安全的List实现)
-
Hashtable (线程安全的Map实现)
-
java.util.concurrent 包下的类
这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。它们的每个方法是原子的,但它们多个方法的组合不是原子的。
不可变类线程安全性:
String为例:String
对象的值一旦创建就无法更改。任何对 String
对象的修改操作(如拼接、替换等)都会返回一个新的 String
对象,原始对象不会受到影响。因此,在多线程环境中,多个线程同时访问一个 String
对象是安全的,因为它们无法修改该对象的值。
4.4 Monitor概念
Java对象头
以32位虚拟机为例:
普通对象
|--------------------------------------------------------------| | Object Header (64 bits) | |------------------------------------|-------------------------| | Mark Word (32 bits) | Klass Word (32 bits) | |------------------------------------|-------------------------|
数组对象
|---------------------------------------------------------------------------------| | Object Header (96 bits) | |--------------------------------|-----------------------|------------------------| | Mark Word(32bits) | Klass Word(32bits) | array length(32bits) | |--------------------------------|-----------------------|------------------------|
其中Mark Word结构为
|-------------------------------------------------------|--------------------| | Mark Word (32 bits) | State | |-------------------------------------------------------|--------------------| | hashcode:25 | age:4 | biased_lock:0 | 01 | Normal | |-------------------------------------------------------|--------------------| | thread:23 | epoch:2 | age:4 | biased_lock:1 | 01 | Biased | |-------------------------------------------------------|--------------------| | ptr_to_lock_record:30 | 00 | Lightweight Locked | |-------------------------------------------------------|--------------------| | ptr_to_heavyweight_monitor:30 | 10 | Heavyweight Locked | |-------------------------------------------------------|--------------------| | | 11 | Marked for GC | |-------------------------------------------------------|--------------------|
Monitor原理
内置锁在Java中被抽象为监视器锁(monitor),每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针。
-
synchronized 必须是进入同一个对象的 monitor 才有上述的效果
-
不加 synchronized 的对象不会关联监视器,不遵从以上规则
4.5 synchronized原理进阶
-
轻量级锁
轻量级锁的目标是,减少无实际竞争情况下,使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。轻量级锁可适用于锁的持有时间较短的场景。
使用轻量级锁时,不需要申请互斥量,仅仅将Mark Word中的部分字节CAS更新指向线程栈中的Lock Record,如果更新成功,则轻量级锁获取成功,记录锁状态为轻量级锁;由于轻量级锁天然瞄准不存在锁竞争的场景,如果存在锁竞争但不激烈,仍然可以用自旋锁优化,自旋失败后再膨胀为重量级锁。
假设有两个方法同步块,利用同一个对象加锁:
创建锁记录(Lock Record)对象,每个线程都的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word。让锁记录中 Object reference 指向锁对象,并尝试用 cas 替换 Object 的 Mark Word,将 Mark Word 的值存 入锁记录
如果 cas 替换成功,对象头中存储了 锁记录地址和状态 00 ,表示由该线程给对象加锁,
如果 cas 失败,有两种情况:
-
如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程
-
如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数
当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一。
当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 CAS 将 Mark Word 的值恢复给对象头
-
成功,则解锁成功。
-
失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
2. 锁膨胀
如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时就是有其它线程为此对象加上了轻量级锁(有竞争),需要进行锁膨胀,将轻量级锁变为重量级锁。
当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级
这时 Thread-1 加轻量级锁失败,进入锁膨胀流程:即为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址,然后自己进入 Monitor 的 EntryList BLOCKED
当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,失败。这时会进入重量级解锁 流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程
3. 自旋优化
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步 块,释放了锁),这时当前线程就可以避免阻塞。
自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
在 Java 6 之后自旋锁是自适应的,如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋。 Java 7 之后不能控制是否开启自旋功能。
4. 偏向锁
轻量级锁在没有竞争时,每次重入仍然需要执行 CAS 操作。 Java 6 中引入了偏向锁来做进一步优化:第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有。
调用对象 hashCode,其它线程使用对象(升级为轻量级锁)和调用 wait/notify(重量级)会撤销偏向锁
批量重偏向和撤销:
-
当撤销偏向锁阈值超过 20 次后,jvm 会在加锁时重新偏向至加锁线程。
-
当撤销偏向锁阈值超过 40 次后,jvm 会将整个类的所有对象设为不可偏向。
-
JIT即时编译器会对不必要的同步操作进行优化,进行锁消除。
-
锁粗化是指JVM在运行时将多个连续的同步代码块合并为一个更大的同步代码块的优化技术。
4.6 wait和notify
wait()
和 notify()
是 Java 中用于线程间通信的方法,它们通常与 synchronized
关键字一起使用,用于实现线程之间的协作。
-
wait() 方法:
-
wait()
方法用于使当前线程进入等待状态,并释放对象锁。 -
线程在等待状态时不会消耗 CPU 资源,直到被唤醒或等待时间到期。
-
-
notify() 和 notifyAll() 方法:
-
notify()
方法用于唤醒一个正在等待同一个对象锁的线程。 -
notifyAll()
方法用于唤醒所有正在等待同一个对象锁的线程。 -
被唤醒的线程将重新进入竞争锁的状态。
-
以下是它们的基本使用模式:
// 在 synchronized 块内调用 wait() 方法
synchronized (obj) {
while (conditionIsNotMet) {
obj.wait(); // 当条件不满足时,线程等待并释放对象锁
}
// 线程被唤醒后,继续执行
}
// 在 synchronized 块内调用 notify() 或 notifyAll() 方法
synchronized (obj) {
// 改变条件
obj.notify(); // 或 obj.notifyAll(); 唤醒等待该对象锁的一个或全部线程
}
需要注意的是,调用 wait()
、notify()
或 notifyAll()
方法前,线程必须先获取对象的锁,否则会抛出 IllegalMonitorStateException
异常。此外,线程应该在循环中调用 wait()
,以防止虚假唤醒(spurious wakeups)。
sleep(long n) 和 wait(long n) 的区别
-
sleep 是 Thread 方法,而 wait 是 Object 的方法
-
sleep 不需要强制和 synchronized 配合使用,但 wait 需要 和 synchronized 一起用
-
sleep 在睡眠的同时,不会释放对象锁的,但 wait 在等待的时候会释放对象锁
4.7 park和unpark
它们是 LockSupport 类中的方法,可以更灵活地控制线程的状态。park()
方法是一个静态方法,可以通过 Unsafe
类或 LockSupport
工具类调用。unpark()
方法可以在任何时候调用,即使在 park()
方法之前。
Thread thread = Thread.currentThread();
// 阻塞当前线程
LockSupport.park();
// 唤醒指定线程
LockSupport.unpark(thread);
LockSupport类最终是调用了Unsafe.park()方法。LockSupport 的主要功能是提供每个线程一个相关联的许可(permit),当调用 unpark(thread)
方法时,该线程会获取许可,以便在调用 park()
方法时不会被阻塞。
Unsafe类是在sun.misc包下,提供了一种底层、"不安全"的机制来直接访问和操作内存、线程和对象。
4.8 线程状态转换
1. NEW --> RUNNABLE:当调用 t.start()
方法时,由 NEW --> RUNNABLE
t 线程用synchronized(obj)
获取了对象锁后:
2. RUNNABLE <--> WAITING / TIMED_WAITING:
-
调用
obj.wait()
方法时,t 线程从RUNNABLE --> WAITING
-
调用
obj.wait(long n)
方法时,t 线程从RUNNABLE --> TIMED_WAITING
3. WAITING / TIMED_WAITING <--> RUNNABLE / BLOCKED
-
调用
obj.notify()
,obj.notifyAll()
,t.interrupt()
时,或者等待时间超过了n毫秒:-
竞争锁成功,t 线程从
WAITING / TIMED_WAITING --> RUNNABLE
-
竞争锁失败,t 线程从
WAITING / TIMED_WAITING --> BLOCKED
-
3. RUNNABLE <--> BLOCKED :
-
如果竞争失败,从
RUNNABLE
-->BLOCKED
-
持 obj 锁线程的同步代码块执行完毕,会唤醒该对象上所有
BLOCKED
的线程重新竞争,如果其中 t 线程竞争 成功,从BLOCKED --> RUNNABLE
,其它失败的线程仍然BLOCKED
。
4. RUNNABLE <--> TERMINATED:当前线程所有代码运行完毕,进入 TERMINATED
4.9 死锁
死锁发生在两个或多个线程互相持有对方所需的资源,并且由于等待对方释放资源而导致所有线程无法继续执行的情况。例如,t1线程已经获得A对象锁,想获取 B对象的锁,而t2线程已经获得B对象锁,想获取A对象的锁。
死锁通常包含以下四个必要条件:
-
互斥条件:至少有一个资源是排它性的,即一次只能由一个线程使用。如果一个线程已经获取了某个资源,其他线程就无法同时获取该资源。
-
请求与保持条件:线程至少已经获取了一个资源,并且正在等待获取另一个被其他线程持有的资源。
-
不可剥夺条件:已经分配给线程的资源不能被强制性地剥夺,只能由持有它的线程显式释放。
-
环路等待条件:存在一个资源的循环等待链,即若干线程形成一个循环,每个线程都在等待下一个线程所持有的资源。
检测死锁可以使用 jconsole工具,或者使用 jps 定位进程 id,再用 jstack 定位死锁:
活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束
饥饿(Starvation)指的是某个线程由于无法获取所需的资源而无法继续执行的情况。
4.10 ReentrantLock
1. 可重入性(Reentrancy)
-
与 synchronized 一样可重入,即一个线程在持有锁的情况下可以再次获取相同的锁,而不会被阻塞。
2. 公平性选择(Fairness)
-
ReentrantLock
提供了一个构造函数,允许选择是否使用公平性策略。在公平性策略下,线程会按照它们等待锁的顺序获取锁,从而避免某些线程长时间等待锁而导致饥饿。(默认是不公平)
3. 锁获取的可中断性
-
ReentrantLock
提供了lockInterruptibly()
方法,允许线程在获取锁的过程中响应中断。 -
如果线程在等待获取锁的过程中被中断,它可以选择放弃等待或者继续等待,以便于线程能够更灵活地处理中断事件。
4. 条件变量(Condition):
-
支持多个条件变量,
newCondition()
方法用于创建一个新的条件变量。 -
当前线程会在调用
await()
方法后释放锁,并进入等待状态,直到其他线程调用相同条件变量的signal()
或signalAll()
方法来唤醒它,或者当前线程被中断。 -
条件变量的使用方式通常如下:
ReentrantLock lock = new ReentrantLock();
Condition condition = lock.newCondition();
// 线程 1
lock.lock();
try {
while (!conditionSatisfied()) {
condition.await(); // 当条件不满足时,线程进入等待状态并释放锁
}
// 执行任务
} finally {
lock.unlock();
}
// 线程 2
lock.lock();
try {
// 修改条件
condition.signal(); // 唤醒一个等待的线程
} finally {
lock.unlock();
}
5. 非阻塞的锁获取:
-
ReentrantLock
提供了tryLock()
方法,允许线程尝试获取锁而不阻塞,且可以被打断。 -
如果锁已经被其他线程持有,
tryLock()
会立即返回失败,而不是让线程进入阻塞状态,这样可以避免长时间等待锁而导致饥饿。 -
tryLock(long time, TimeUnit unit)
方法在拿不到锁时会等待一定的时间,在时间期限之内如果还拿不到锁,就返回false。如果如果一开始拿到锁或者在等待期间内拿到了锁,则返回true。