目录:
(管程)
1.1 synchronized锁
在介绍 synchronized 之前,先介绍两个概念:
- 临界区(Critical Section):一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区。
- 竞态条件(Race Condition):多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件。
-
阻塞式的解决方案:
synchronized
,
Lock -
非阻塞式的解决方案:原子变量
俗称 对象锁 ,它采用互斥的方式让同一
时刻至多只有一个线程能持有 对象锁 ,其它线程再想获取这个 对象锁 时就会阻塞(Blocked)住。这样就能保证拥有锁
的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。
语法:
synchronized(锁对象) // 线程1, 线程2(blocked)
{
临界区代码
}
若线程1持有此锁对象,则线程2要想访问此临界区代码,需要等待线程1执行完synchronized中的代码后归还此锁对象,并竞争到此锁对象后,才能访问临界区的代码,等待过程中线程处于阻塞(blocked)状态。synchronized 实际是用对象锁保证了临界区内代码的原子性,即临界区内的代码对外是不可分割的,不会被线程切换所打断。
【修饰方法的 synchronized】
修饰普通方法的 synchronized:锁对象是此实例对象(this)
class Test{
public synchronized void test() {
}
}
// 等价于
class Test{
public void test() {
synchronized(this) {
}
}
}
修饰静态方法的 synchronized:锁对象是此类对象(类名.class)
class Test{
public synchronized static void test() {
}
}
// 等价于
class Test{
public static void test() {
synchronized(Test.class) {
}
}
}
没有被 synchronized 修饰的方法,是无法保证方法原子性的。
1.2 变量的线程安全分析
【什么是线程安全?什么是线程不安全?】
线程安全就是多线程访问时,采用了加锁机制,当一个线程访问该类的某个数据时,进行保护,其他线程不能进行访问直到该线程读取完,其他线程才可使用。不会出现数据不一致或者数据污染。 线程不安全就是不提供数据访问保护,有可能出现多个线程先后更改数据造成所得到的数据是脏数据
【成员变量和静态变量是否线程安全?】
【局部变量是否线程安全?】
方法中的局部变量
,会在每个线程的栈帧内存中都被创建一份,因此不存在共享 。但局部变量引用的对象则未必:如果该对象没有逃离方法的作用范围,它是线程安全的;如果该对象逃离方法的作用范围,需要考虑线程安全。
因为虚拟机栈是线程私有的,其栈帧中的局部变量表也是每个线程独有一份的,所以局部变量是线程安全的。但如果其发生了逃逸,则就不是线程安全的了。
这里关于虚拟机栈(帧)相关知识不懂的可以参考
【常见线程安全的类有哪些?】
- String
- Integer
- StringBuffer
- Random
- Vector
- Hashtable
- java.util.concurrent 包下的类
String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的。
注意:这些类中的单个方法都是原子的,但是他们多个方法组合起来使用就不是原子的了。例如:
Hashtable table = new Hashtable();
// 线程1,线程2
if( table.get("key") == null) {
table.put("key", value);
}
Hashtable 的 get 方法和 put 方法都是线程安全的,但是当他们组合起来使用时,就不是线程安全的了,因为他们只对自身内部的具体实现加了锁。 get 方法内部源码剖析(put 方法类似):
1.3 Monitor 概念
在介绍 Monitor 之前,我们先来了解一下对象头: 普通对象的对象头包含两部分:
- 运行时元数据(Mark Word):哈希值、GC年龄分代、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳
- 类型指针:指向类元数据,确定该对象所属的类型
- 如果是数组,还需要记录数组的长度
Mark Word 的结构:
具体可以去了解一下,这里就不细讲了。
Monitor :
被翻译为监视器或管程,由操作系统提供,相当于临界区的监管者。每一个Java对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针。
Monitor 结构如下:
- Owner 记录当前正在访问此临界区域的线程(Thread-2)
- EntryList 记录当前正在排队而进入阻塞状态的线程(Thread-3、4、5),当 Thread-2 执行完同步代码块中的内容后,然后唤醒 EntryList 中等待的线程来竞争锁,竞争时是非公平
- Waiting 中的 Thread-0、1 线程是之前获得过锁,但条件不满足进入 WAITING (阻塞)状态的线程,WAITING 线程会在 Owner 线程调用 notify() 或 notifyAll() 时唤醒,但唤醒之后并不意味着立刻获得锁,仍需进入 EntryList 重新竞争。
注意:synchronized 必须是进入同一个对象的 monitor 才有上述结果。不加 synchronized 的对象不会关联监视器,不遵守以上规则。
1.4 synchronized 原理进阶
【轻量级锁】
如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。轻量级锁对使用者是透明的,即语法仍然是 synchronized,加锁时会优先考虑轻量级锁,如果失败了(有竞争)才会考虑重量级锁。
假设有两个方法同步块,利用同一个对象加锁:
static final Object obj = new Object();
public static void method1() {
synchronized (obj) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized (obj) {
// 同步块 B
}
}
- 创建锁记录(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 的值恢复给对象头。成功:则解锁成功;失败:说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程。
【锁膨胀】
如果在尝试加轻量级锁的过程中, cas 操作无法成功,这时一种情况就是有其他线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变成重量级锁。
- 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁,这时 Thread-1 加轻量级锁失败,进入锁膨胀流程:即为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址,然后自己进入 Monitor 的 EntryList BLOCKED
- 当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null ,唤醒 EntryList 中的 BLOCKED 线程。
【自旋优化】
重量级锁竞争的时候,还可以使用自旋来优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。自旋会占用 CPU 的时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
【偏向锁】
轻量级锁在没有竞争时(就自己这一个线程),每次重入仍然需要执行 cas 操作。Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 cas 时将线程 ID设置到对象头的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 cas 。只要以后不发生竞争,这个对象就归该线程所有。
回顾对象头格式:
一个对象创建时:
- 如果开启了偏向锁(默认开启),那么对象创建后,Mark Word 值为 0x05 即最后三位为 101,这时它的 thread、epoch、age 都为 0。
- 偏向锁默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 -XX: BiasedLockingStartupDelay=0 来禁用延迟。添加 VM 参数 -XX: -UseBiasedLocking 可以禁用偏向锁。
- 在开启偏向锁的状态时,如果调用了对象的 hashcode() 方法,则偏向状态将会失效。因为 hash 码会占用偏向锁中存储 thread 的位置。(轻量级锁会在锁记录中记录 hashcode ,重量级锁会在 Monitor 中记录 hashcode)
- 当有其他线程使用偏向锁对象时,会将偏向锁升级为轻量级锁。
- 如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的锁对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID。当撤销偏向锁阈值超过 20 次后,jvm 会觉得是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程;当撤销偏向锁阈值超过 40 次后,jvm 会觉得确实偏向错了,于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的。
1.5 wait & notify
obj.wait() // 让进入 object 监视器的线程到 waitSet 等待
obj.wait(long n) // 有时限的等待, 到 n 毫秒后结束等待,或是被 notify
obj.notify() // 在 object 上正在 waitSet 等待的线程中挑一个唤醒
obj.notifyAll() // 让 object 上正在 waitSet 等待的线程全部唤醒
它们都是线程之间进行协作的手段,都属于 Object 对象的方法。必须获得此对象的锁,才能调用这几个方法。
和
wait(long n)
的区别是什么?
-
sleep
是
Thread
方法,而
wait
是
Object
的方法
。 -
sleep
不需要强制和
synchronized
配合使用,但
wait
需要和 synchronized
一起用。 -
sleep
在睡眠的同时,不会释放对象锁的,但
wait
在等待的时候会释放对象锁。 -
它们状态都是 TIMED_WAITING。
synchronized(lock) {
while(条件不成立) {
// 条件成立时退出循环,否则每次被唤醒都要重新进入wait
lock.wait();
}
// 干活
}
//另一个线程
synchronized(lock) {
lock.notifyAll();
}
【同步模式之保护性暂停】
即 Guarded Suspension,用于一个线程等待另一个线程的执行结果。
GuardedObject 的代码可如下:
class GuardedObject{
// 结果
private Object result;
// 获取结果
public Object getResult() {
synchronized (this) {
// 没有结果,就进入等待模式
while (result== null) {
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return result;
}
}
// 设置结果
public void setResult(Object result) {
synchronized (this) {
// 给结果成员变量赋值
this.result=result;
// 唤醒等待获取结果的线程
this.notifyAll();
}
}
}
【异步模式之生产者与消费者】
与前面的保护性暂停中的 GuardObject 不同,不需要产生结果和消费结果的线程一一对应。消费队列可以用来平衡生产和消费的线程资源。生产者仅负责产生结果数据,不关心数据该如何处理,而消费者专心处理结果数据消息队列是有容量限制的,满时不会再加入数据,空时不会再消耗数据。JDK 中各种阻塞队列,采用的就是这种模式。
消息队列带代码示例:
// 消息队列类,java 线程之间通信
class MessageQueue {
private static final Logger log = LoggerFactory.getLogger(MessageQueue.class);
// 消息的队列集合
private LinkedList<Message> queue = new LinkedList<>();
// 队列容量
private int capacity;
public MessageQueue(int capacity) {
this.capacity = capacity;
}
// 获取消息
public Message take() {
synchronized (queue) {
// 检查队列是否为空
while (queue.isEmpty()) {
log.debug("没货了, wait");
try {
queue.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 从队列的头部获取消息返回
Message message = queue.removeFirst();
log.debug("已消费消息{}",message);
queue.notifyAll();
return message;
}
}
// 存入消息
public void put(Message message) {
synchronized (queue) {
// 检查队列是否已满
while (queue.size() == capacity) {
log.debug("库存已达上限, wait");
try {
queue.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
queue.addLast(message);
log.debug("已生产消息{}", message);
queue.notifyAll();
}
}
}
1.6 Park & Unpark
// 暂停当前线程
LockSupport.park();
// 恢复某个线程的运行
LockSupport.unpark(暂停线程对象)
调用 LockSupport.park() 方法的线程处于 WAITING 状态。
Park & Unpark 与 Wait & Notify 的区别?
- wait,notify 和 notifyAll 必须配合 Object Monitor 一起使用,而 park,unpark 不必。
-
park & unpark
是以线程为单位来阻塞和唤醒线程,而
notify
只能随机唤醒一个等待线
程,
notifyAll 是唤醒所有等待线程,就不那么精确。 -
park & unpark
可以先
unpark
,而
wait & notify
不能先 notify。
park & unpark
先
unpark,后 park 时,线程依然可以继续运行(相当于先喝解药、后喝毒药,依然能救活)。
1.7 活跃性
【死锁】
例如:t1 线程拥有 A对象 锁,t2 线程拥有 B对象 锁。接下来 t1 线程想获取 B对象 的锁,t2 线程想获取 A对象 的锁,就会导致两个线程都无法获取想要的锁,也都无法继续向下运行,就会导致死锁。
jconsole工具(直接在左下角搜索);或者使用 jps
定位进程
id
,再用
jstack 定位死锁(在命令行中输入)。
【活锁】
活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束,这种现象成为活锁。
【饥饿】
一个线程由于优先级太低,始终得不到 CPU 调度执行,也不能够结束,这种现象成为饥饿。
1.8 ReentrantLock(可重入锁)
ReentrantLock 的基本语法:
// 获取锁
reentrantLock对象.lock();
try {
// 临界区
} finally {
// 释放锁
reentrantLock.unlock();
}
相对于 synchronized ,ReentrantLock 具备以下特点:
-
可中断(指别的线程可以破环你的 BLOCKING 状态,而不是指自己中断阻塞状态)
-
可以设置超时时间(即设置处于 BLOCKING 的时间)
-
可以设置为公平锁(先到先得)
-
支持多个条件变量(即多个 WaitSet)
与 synchronized
一样,都支持可重入。接下来,我们依次讲解一下这几个特性:
【可重入】
可重入是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此有权利再次获取这把锁。 如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住。
【可打断】
当调用 ReentrantLock对象.lockInterruptibly() 方法获取锁时,当该锁在 EntryList 中阻塞时,就可以通过其他线程调用 interrupt() 方法打断其 BLOCKING 状态(被动)。这种机制可以避免线程在 EntryList 中 “死等”,从而避免死锁的发生。
ReentrantLock对象.lockInterruptibly()
【锁超时】
此机制可在获取到是否获取到锁的结果后,主动地打断处于 BLOCKING 状态的线程。此方法也包了可打断机制。
// 返回true:表示成功获取锁; 返回false:表示未获取到锁
ReentrantLock对象.tryLock(long time,TimeUint uint);// 形参time表示等待的时间,不加则不等待
【公平锁】
默认是不公平。但我们可以通过设置 ReentrantLock 的构造方法来设计其公平性。
ReentrantLock lock = new ReentrantLock(true);// true表示公平, false表示不公平
【条件变量】
中也有条件变量,就是我们讲原理时那个
waitSet
休息室,当条件不满足时进入
waitSet
等待。ReentrantLock 的条件变量比
synchronized
强大之处在于,它是支持多个条件变量的,这就好比 synchronized 是那些不满足条件的线程都在一间休息室等消息,而 ReentrantLock
支持多间休息室,有专门等烟的休息室、专门等早餐的休息室、唤醒时也是按休息室来唤醒。
锁对象.newCondition().await(long time,TimeUint uint);// 进入此休息室等待
锁对象.newCondition().signal();// 唤醒此休息室的某一个线程
锁对象.newCondition().signalAll();// 唤醒此休息室的所有个线程
await 前需要获得锁,await 执行后,会释放锁,进入 conditionObject 等待。await 的线程被唤醒(或打断、或超时)取重新竞争 lock 锁,竞争 lock 锁成功后,从 await 后继续执行
1.9 经典面试题
至此,我们可以来一道超级经典的面试题:交替输出 abc,即 要求输出 abcabcabcabcabc 该如何实现?(其中线程1 输出 a 5 次,线程2 输出b 5 次,线程3 输出 c 5 次)。
分别通过 synchronized & wait & notify 、 ReentrantLock & lock & unlock 和 LockSupport & park & unpark 三种方法来解决。
详情见:
【同步与互斥】
互斥:使用 synchronized 或 Lock 达到共享资源互斥效果,即临界区的代码不会因为上下文的切换而产生指令的交错,保证临界区代码的原子性。
同步:使用 wait/notify 或 Lock 的条件变量来达到线程间通信效果,即当条件不满足时让线程进入等待,当条件满足时恢复运行。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/2147.html