线程

  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的获得资源,失败的则进入自旋 。

  • 第五步,自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于轻量级锁的状态,如果自旋失败。

  • 第六步,进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己。