线程
blocked状态往往是进入同步代码块之前获取锁失败而导致的,也就是进入一段synchronized方法块;而ReentrantLock.lock()操作后进入的是 WAITING状态,其内部调用的是LockSupport.park()方法,线程是进入waiting状态。事实上,java.concurrent包中Lock接口对于阻塞的实现都是 使用LockSupport类中的相关方法。也就是这里的"阻塞"线程都是进入了waiting状态。
一道面试题: 两个线程交替打印奇数和偶数。这是一道经典题目,还有一些变种,就是考察你是用wait和notify。这里的核心在于线程一,先打印第一个 奇数后,需要通知另一个阻塞在当前锁上的线程,然后自己主动调用wait释放锁,否则只有等同步块代码执行完成后,才会释放锁。注意到这一点写代码就没 那么难了,参考:
public class WaitNotifyPrint {
private static Object object = new Object();
static class ThreadA implements Runnable {
@Override
public void run() {
synchronized (object) {
for (int i = 1; i < 10; i++) {
if (i % 2 == 1) {
System.out.println("ThreadA: " + i);
try {
//注意,这两行代码的位置是可以交换的,但是两个线程至少得有一个先notify,不能都先调用wait,否则这样会死锁
object.wait();
object.notify();
}
catch (InterruptedException e) {
e.printStackTrace();
}
}
}
object.notify();
}
}
}
static class ThreadB implements Runnable {
@Override
public void run() {
synchronized (object) {
for (int i = 1; i < 10; i++) {
if (i % 2 == 0) {
System.out.println("ThreadB: " + i);
try {
object.notify();
object.wait();
}catch (InterruptedException e) {
e.printStackTrace();
}
}
}
//注意,这一行代码是必须的,要不你的进程不会结束,因为两个线程还一直处于wait状态
object.notify();
}
}
}
public static void main(String[] args) {
new Thread(new ThreadA()).start();
new Thread(new ThreadB()).start();
}
}
-
wait方法可以不指定时间,sleep方法必须制定时间。
-
wait会释放占用的CPU资源,同时会释放锁;但是sleep方法不会释放锁,只是释放CPU资源。
-
wait方法必须放在同步块代码中,即必须先获取当前object的锁。
内存模型
我们都知道,在Java程序运行时,内存是划分为几个区域的:本地方法栈、虚拟机栈和程序计数器(线程私有),堆和方法区(线程共享)。堆既然是共享的, 为什么在堆中的变量还存在内存可见性呢?
这是因为高速缓存,CPU往往直接访问的是高速缓存,而不是内存;这里Java内存模型(JMM)定义了主内存和本地内存(这两个是抽象概念), 主内存就是堆内存,线程之间的共享变量存在主内存中;而每个线程都有一个自己私有的本地内存(实际并不存在,抽象概念),存储了该线程读写的共享变量 的副本,这个本地内存涵盖了缓存、写缓冲区、寄存器等一堆东西。
所谓线程之间的通信是通过共享内存(Java中),就是JMM控制的这一套体系,多个线程读取同一个共享变量时,需要经过主内存。线程A无法直接 访问线程B的工作内存,线程间通信必须经过主内存。这里JMM有一条规定:线程对共享变量的操作(读写)都必须在自己的本地内存中进行,不能直接读写 主内存中的变量。
所以这里,线程B并不是直接去主内存中读取共享变量的值,而是先在本地内存B(线程私有的)找到这个共享变量,然后发现这个共享变量已经被更新过了,然后 本地内存B去主内存中读取这个共享变量的最新值,并拷贝到本地内存B中;最后线程B再去读取本地内存B中的新值。那么这里是如何发现我本地内存中的这个 变量已经是脏的呢?这里就依赖JMM,JMM通过控制主内存和各个线程的本地内存之间的交互,来提供内存可见性保证。
这一段的核心在于:线程只能和自己的本地内存交互,而本地内存才是和主内存进行交互。
一般来说,JMM中的主内存属于共享数据区域,他是包含了堆和方法区;同样,JMM中的本地内存属于私有数据区域,包含了程序计数器、本地方法栈、虚拟机栈。
volatile
volatile修饰的变量保证了线程A对其的修改,会立即刷新到主内存;同时线程B的读取会将其本地内存中的值设置为无效,从而需要本地内存强制从主内存中 读取最新的值,这样线程B也就获取到了最新的值。但是这里需要注意的是:普通(非volatile)变量的修改虽然短暂是不可见的,但是并不意味着一定就获取 不到,JVM会尽可能的将修改后的值刷新回主内存,但是这个时间gap就不那么可控了。
volatile的原理是内存屏障,是编译器在生成字节码的时候,会在指令序列中插入特定类型的内存屏障来禁止指令重排序。
锁
锁都是基于对象的(实例或者Class对象),锁信息存放在对象头中,一个对象其实是有4种锁状态的:
-
无锁状态
-
偏向锁状态
-
轻量级锁状态
-
重量级锁状态
这几种状态会随着竞争而逐渐升级,不过锁也是可以降级的(HotSpot JVM就支持),降级发生在STW期间,当JVM进入安全点时,会检查是否有闲置的锁, 然后进行降级。
每个Java对象都有对象头。如果是非数组类型,则用2个字宽来存储对象头,如果是数组,则会用3个字宽来存储对象头。在32位处理器中,一个字宽是32位; 在64位虚拟机中,一个字宽是64位。
当对象状态是偏向锁时,Mark Word存储的是偏向的线程ID;如果是轻量级锁,Mark Word存储的是指向线程栈中锁记录(Lock Record)的指针;如果 是重量级锁,Mark Word存储的是指向互斥量(堆中的monitor对象)的指针。
锁升级的过程
一、偏向锁:一个线程在第一次进入同步块时,会在对象头和线程的栈帧中的锁记录里存储当前的线程ID,当这个线程再次进入这个同步块时,会先检测对象 头中的Mark Word里面是不是存放的自己的线程ID,如果是,就代表该线程之前已经获取到了锁,那么后续将不再花费CAS操作来加锁和解锁;如果不是,那么意味着 这个锁已经被其他线程持有过(或者持有中),就尝试使用CAS操作(自旋)来替换对象头中的Mark Word里面的线程ID,替换成自己的:
-
如果替换成功,那么表示之前的线程已经不存在了,锁不会升级,还是偏向锁;
-
如果替换失败,那么意味着这个锁仍然被持有,表示之前的线程还存在;那么暂停之前的线程,设置偏向锁标识为0,并设置锁标志位是00,升级为 轻量级锁,将对象头Mark Word中锁记录的指针指向当前堆栈中最近的一个lock record,接着按照轻量级锁的方式进行竞争。
二、撤销偏向锁:偏向锁的撤销机制是等到竞争出现才释放锁,也就是如果一直没有竞争,那么一直都只维持着偏向锁的状态。偏向锁升级为轻量级锁的时, 会暂停拥有偏向锁的线程,重置偏向锁标识,开销并不算小,过程如下:
-
1、在一个安全点(在这个时间点上没有字节码在执行)停止拥有锁的线程;
-
2、遍历全部的线程栈(每一个线程栈),如果存在锁记录的话,需要修复锁记录和对象头中的Mark Word,使其变为无锁状态;
-
3、唤醒被停止的线程,将当前锁升级为轻量级锁。
所以,如果应用程序里所有的锁通常处于竞争状态,那么偏向锁就会是一种累赘,对于这种情况,我们可以一开始就把偏向锁这个默认功能给关闭: -XX:UseBiasedLocking=false。
三、轻量级锁:多个线程在不同的时间去尝试获取同一个对象锁,这时是不存在锁竞争的情况的,也就是是没有线程阻塞的。针对这种情况,JVM采用 轻量级锁来避免线程的阻塞与唤醒。
1、轻量级锁的加锁:JVM在每个线程的栈帧中创建了用于存储锁记录的空间,称之为Displaced Mark Word。如果一个线程获得锁时,发现锁的状态是轻量 级锁,就会把锁的Mark Word复制到自己的Displaced Mark Word里面。
然后线程尝试用CAS将锁的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示Mark Word已经被替换成了其他线程的锁记录, 说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁。自旋一直在消耗CPU资源,JDK采用了适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的 次数会更多,如果自旋失败了,则自旋的次数就会减少。
自旋也不是一直进行下去的,如果自旋到一定程度(和JVM、操作系统相关),依然没有获取到锁,称为自旋失败,那么这个线程会阻塞。同时这个锁就会升级成 重量级锁。
2、轻量级锁的释放:在释放锁时,当前线程会使用CAS操作将Displaced Mark Word的内容复制回锁的Mark Word里面。如果没有发生竞争,那么这个复制 的操作会成功。如果有其他线程因为自旋多次导致轻量级锁升级成了重量级锁,那么CAS操作会失败,此时会释放锁并唤醒被阻塞的线程。
四、重量级锁:它是基于操作系统的互斥量实现的,效率会比较低,但是被阻塞的线程不会消耗CPU。
总结锁的升级流程:
-
每一个线程在准备获取共享资源时: 第一步,检查MarkWord里面是不是放的自己的ThreadId ,如果是,表示当前线程是处于偏向锁” 。
-
第二步,如果MarkWord不是自己的ThreadId,锁升级,这时候,用CAS来执行切换,新的线程根据MarkWord里面现有的ThreadId,通知之前线程暂停,之前 线程将Markword的内容置为空。
-
第三步,两个线程都把锁对象的HashCode复制到自己新建的用于存储锁的记录空间,接着开始通过CAS操作, 把锁对象的MarKword的内容修改为自己新建的记录 空间的地址的方式竞争MarkWord。
-
第四步,第三步中成功执行CAS的获得资源,失败的则进入自旋 。
-
第五步,自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于轻量级锁的状态,如果自旋失败。
-
第六步,进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己。