深入理解Java并发框架AQS系列(一):线程

深入理解Java并发框架AQS系列(一):线程
深入理解Java并发框架AQS系列(二):AQS框架简介及锁概念
深入理解Java并发框架AQS系列(三):独占锁(Exclusive Lock)
深入理解Java并发框架AQS系列(四):共享锁(Shared Lock)
深入理解Java并发框架AQS系列(五):条件队列(Condition)

一、概述

1.1、前言

重剑无锋,大巧不工

j.u.c包下的源码,永远无法绕开的经典并发框架AQS,其设计之精妙堪比一件艺术品,令众多学者毫不吝惜溢美之词。近期准备出一系列关于AQS的文章,系统的来讲解AQS,我将跟大家一起带着敬畏之心去读她,但也会对关键部分提出质疑及思考

本来打算直接以阐述锁概念作为开头,但发现始终都绕不过线程这关,再加上现在好多讲述线程的文章概念混淆不清,误人子弟,索性开此文,一来做一些基础工作的铺垫,二来我们把线程的一些概念聊透

1.2、名词释义

名词 描述
j.u.c 本文特指java.util.concurrent
AQS 本文特指围绕j.u.c包下的类AbstractQueuedSynchronizer.java提供的一套轻量级并发框架

二、线程状态

线程状态属于老生常谈的话题,在网上一搜一大把,但发现很多文章都是人云亦云。我们将结合代码实例来逐一论述线程状态。

我尝试想用一张图把状态流转描述清楚,发现非常困难,由于wait/notify使用的特殊性,会将整个流程图搅得很乱,所以此处我们把状态流转拆分为(非wait方法)及(wait方法)。如果你在某些文章中看到用一张图来描述线程状态流转的,那么要留心了,仔细甄别下,看其是否遗漏了某些场景

站在JVM的视角,将线程状态分成了6种状态:

  • NEW-初始
  • RUNNABLE-可运行
  • BLOCKED-阻塞
  • WAITING-等待
  • TIMED_WAITING-超时等待
  • TERMINATED-结束

为了论述的更为彻底,我们站在操作系统的角度,将RUNNABLE-可运行状态拆分为runnable-就绪状态running-运行状态,故一共7种状态

2.1、状态定义

2.1.1、初始状态(new)

线程在新建后,且在调用start方法前的状态为初始状态,此时操作系统感知不到线程的存在,仅存在于JVM内部

2.1.2、就绪状态(runnable)

就绪状态表示当前线程已经启动,只要操作系统调度了cpu时间片,即可运行,其本质上还是处于等待;例如3个正常启动且无阻塞的线程,运行在一个2核的计算机上,那么在某一个时刻,一定至少有1个线程处于就绪状态,等待着cpu资源

2.1.3、运行状态(running)

唯一一个正在运行中的状态,且当前线程没有阻塞、休眠、挂起等;处于此状态的线程,通过主动调用Thread.yield()方法,可变为就绪状态

2.1.4、阻塞状态(blocked)

线程被动地处于synchronized的阻塞队列中,没有超时概念、不响应中断

2.1.5、等待状态(waiting)

顾名思义,线程处于主动等待中,且响应中断;当线程主动调用了以下3个方法时,即处于等待状态,等待其他线程的唤起

  • Thread.join()
  • LockSupport.park()
  • Object.wait()

与阻塞状态的区别:

  • 阻塞状态:线程总是被动的处于阻塞状态,当一个线程执行synchronized代码块时,它不知道自己马上抢到锁并执行后续逻辑还是会被阻塞
  • 等待状态:线程很清楚自己接下来要处于等待状态,而且这个命令是线程自己发起的,即便何时被唤醒它无法控制

2.1.6、超时等待状态(timed_waiting)

此状态与waiting状态定义基本一致,只是引入了超时概念;进入timed_waiting的方法如下:

  • Thread.sleep(long)
  • Thread.join(long)
  • LockSupport.parkNanos(long)
  • LockSupport.parkUntil(long)
  • Object.wait(long)

2.1.7、终止状态(terminated)

线程运行完毕,处于此状态的线程不能再次启动,也不能转换为其他状态,等待垃圾回收

2.2、状态流转

初始 -> 就绪

线程调用Thread.start()方法即可进入就绪状态

就绪 -> 运行

操作系统调度,JVM层面无法干预

运行 -> 就绪

分主动、被动2种方式

  • 1、当前线程的cpu时间片用完,被动进入就绪状态
  • 2、主动调用Thread.yield()

运行 -> 阻塞

2种场景可将一个运行状态的线程变为阻塞状态,且都与synchronized相关

  • 场景1:线程因争抢synchronized锁失败,从而进入等待队列时,线程状态置为blocked

    @Test
    public void test5() throws Exception {
        Object obj = new Object();
        Thread thread1 = new Thread(() - > {
            synchronized(obj) {
                int sum = 0;
                // 模拟线程运行
                while(1 == 1) {
                    sum++;
                }
            }
        });
        thread1.start();
        // 停顿1秒钟后再启动线程2,保证线程1已启动运行
        Thread.sleep(1000);
        Thread thread2 = new Thread(() - > {
            synchronized(obj) {
                System.out.println("进入锁中");
            }
        });
        thread2.start();
        System.out.println("线程1状态:" + thread1.getState());
        System.out.println("线程2状态:" + thread2.getState());
    }
    
    ----------运行结果----------
    线程1状态:RUNNABLE
    线程2状态:BLOCKED
    
  • 场景2:处于Object.wait()的线程在被唤醒后,不会立即去执行后续代码,而且是会重新争抢synchronized锁,争抢失败的即会进入同步队列排序,此时的线程状态同样为blocked

    @Test
    public void test6() throws Exception {
        Object obj = new Object();
        Thread[] threads = new Thread[2];
        for(int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() - > {
                synchronized(obj) {
                    try {
                        obj.wait();
                        // 模拟后续运算,线程不会马上结束
                        while(1 == 1) {}
                    } catch(InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });
            threads[i].setName("线程" + (i + 1));
            threads[i].start();
        }
        Thread.sleep(1000);
        // 激活所有阻塞线程
        synchronized(obj) {
            obj.notifyAll();
        }
        Thread.sleep(1000);
        System.out.println("线程1状态:" + threads[0].getState());
        System.out.println("线程2状态:" + threads[1].getState());
    }
    
    
    ----------运行结果----------
    线程1状态:BLOCKED
    线程2状态:RUNNABLE
    

运行 -> 等待

  • 场景1:调用Thread.join()

    @Test
    public void test7() throws Exception {
        Thread thread1 = new Thread(() - > {
            // 死循环,模拟运行
            while(1 == 1) {}
        });
        thread1.start();
        Thread thread2 = new Thread(() - > {
            try {
                thread1.join();
                System.out.println("线程2开始执行");
            } catch(InterruptedException e) {
                e.printStackTrace();
            }
        });
        thread2.start();
        Thread.sleep(1000);
        System.out.println("线程2状态:" + thread2.getState());
    }
    
    ----------运行结果----------
    线程2状态:WAITING
    
  • 场景2:调用LockSupport.park(),即挂起线程,且只能挂起当前线程

    @Test
    public void test8() throws Exception {
        Thread thread1 = new Thread(LockSupport::park);
        thread1.start();
        Thread.sleep(1000);
        System.out.println("线程1状态:" + thread1.getState());
    }
    
    ----------运行结果----------
    线程1状态:WAITING
    

运行 -> 超时等待

  • 1、Thread.sleep(long)
  • 2、Thread.join(long)
  • 3、LockSupport.parkNanos(long)
  • 4、LockSupport.parkUntil(long)

读者可自行写代码验证,此处不再赘述

等待/超时等待 -> 阻塞

当执行完Object.wait()/Object.wait(long)后,不会马上进入就绪状态,线程间还要继续争抢同步队列的锁,争抢失败的便会进入阻塞状态;在AQS后续的条件队列Condition文章中,还会继续说明

运行 -> 终止

线程正常执行完毕,结束了run方法后便进入终止状态,无法再被唤起,等待GC回收

三、线程概念

3.1、曲折中前进

从线程api那些被@Deprecated标记的方法就能看出,线程的设计发展不是一帆风顺的,那些被标记过时的方法都带来了哪些问题?我们举两个例子来说明

3.1.1、Thread.stop()

这个方法不就是将线程停掉么,能带来什么问题?而且调用此方法后,即便获取了synchronized锁也会自动释放,我们要挂起线程的时候,不也要调用LockSupport.park()方法么

的确,其实万恶之源在于stop()方法可由其他线程调用,其他线程在调用时,不知道目标线程是什么状态,也不知道其是否加锁,或正在执行一些原子操作。

最直接的是会带来2个问题,且都是灾难级别的

3.1.1.1、程序原子性

例如:

public class MyThread extends Thread {
    private int i = 0;
    private int j = 0;
    @Override
    public void run() {
        synchronized(this) {
            ++i;
            try {
                //休眠10秒,模拟耗时操作
                Thread.sleep(10000);
            } catch(InterruptedException e) {
                e.printStackTrace();
            }
            ++j;
        }
    }
    public void print() {
        System.out.println("i=" + i + " j=" + j);
    }
}

我们一定认为synchronized方法中的逻辑是原子操作,即所有线程都尘埃落定后,ij的值一定相等;然而事与愿违,由于stop()的介入,破坏了程序的完整性

其次如果目标线程正在修改某个线程共享变量 ,stop()从天而降,这个共享变量最终形态谁也无法预测,为什么会变成这样,所有线程都大眼瞪小眼;就好比把一头狮子放进澡堂洗澡,出来的时候变成了一只鸡,谁都无法解释,程序也即进入了混乱

3.1.1.2、无法彻底释放的锁

语言层面的锁synchronized在执行stop()方法时会被释放,但j.u.c下或自定义锁就没那么好运了

@Test
public void test10() throws Exception {
    ReentrantLock reentrantLock = new ReentrantLock();
    Thread thread1 = new Thread(() - > {
        reentrantLock.lock();
        try {
            Thread.sleep(1000000);
        } catch(InterruptedException e) {
            e.printStackTrace();
        }
        reentrantLock.unlock();
    });
    thread1.start();
    Thread.sleep(500);
    System.out.println("thread1 状态:" + thread1.getState());
    thread1.stop();
    // 等待线程1结束
    while(thread1.getState() != Thread.State.TERMINATED) {}
    System.out.println("主线程尝试获取锁");
    reentrantLock.lock();
    System.out.println("主线程拿到了锁");
}


----------运行结果----------
thread1 状态:TIMED_WAITING
主线程尝试获取锁

我们看到目标锁永远无法再进入

3.1.2、Thread.suspend() / Thread.resume()

从字面意思可以看出,这2个方法是成对儿出现的

  • Thread.suspend()线程暂停
  • Thread.resume()线程恢复

它们带来的了那个臭名昭著的问题:死锁

@Test
public void test11() throws Exception {
    Object lock = new Object();
    Thread thread1 = new Thread(() - > {
        synchronized(lock) {
            try {
                Thread.sleep(2000000);
            } catch(InterruptedException e) {
                e.printStackTrace();
            } finally {
                System.out.println("执行 finally");
            }
        }
    });
    thread1.start();
    Thread.sleep(500);
    thread1.suspend();
    System.out.println("已经将线程1暂停");
    System.out.println("准备获取lock锁");
    synchronized(lock) {
        System.out.println("主抢到锁了");
    }
}


----------运行结果----------
已经将线程1暂停
准备获取lock锁

上述程序陷入了无尽的等待;因为目标线程虽然已经被suspend,但并不会释放锁,当主线程去尝试加锁时,便陷入了无尽等待

3.1.3、思考

为什么会产生这样的现象?其实终其原因是因为其他线程在无法得知目标线程运行状态的前提下,强制进行kill或暂停,所带来的一系列问题;举个不恰当的例子:张三通过小推车持续搬砖了2个小时,工头在办公室通过传呼下达命令:停止工作!此时张三立即放下手中的活儿,小推车因被张三占用,其他人无法开战工作。所以我们是否应该去提醒,而不是直接下达命令,至于在什么时间、什么地点停止工作由张三来决定呢?这就引出了我们要聊得下一个话题:中断

3.2、线程中断

线程中断并不是将一个正在运行的线程中断而致使其终止;

线程中断仅仅是设置线程的中断标记位,不会对目标线程的运行产生干扰。而只有当目标线程响应了中断,从而自发的抛出异常或结束waiting

后续文章中将讲到的AQS提供的方法都是支持响应中断的,此处我们简单罗列一下常用的响应线程中断的方法

  • Object.wait() / Object.wait(long)
  • Thread.join() / Thread.join(long)
  • Thread.sleep(long)
  • LockSupport.park() / LockSupport.parkNanos(long) / LockSupport.parkUntil(long)

那么JVM内部是如何实现响应中断呢?拿Thread.sleep(long)举例,看其C++源码会发现,JVM会将一次长睡眠分割为多次小的睡眠,目标就是及时响应中断

我们延续3.1小节的例子:张三通过小推车持续搬砖了2个小时,妻子看到后说“喝口水,歇会儿吧”(发送打断命令),此时张三的反应可分为以下2类:

  • 感觉不累,继续工作(不响应中断)
  • 把东西归置完毕、小推车归还后,开始休息(让出资源,并在合适的时机休息)

3.3、线程阻塞与挂起

主要讨论wait/notifypark/unpark,两者既然都支持线程的挂起及激活,有什么异同点吗?各自的应用场景何在?

  • 相同点

    • 两者都实现现成挂起、唤醒功能,且支持超时等待、响应中断
  • 不同点

    功能点 精准控制 执行顺序 中断
    wait/notify 挂起:指定当前线程挂起

    唤醒:随机唤醒 1 个线程或全部唤醒
    执行顺序需要严格保证wait操作发生在notify之前,如果notifywait之前执行了,那么wait操作将进入无限等待的窘境 响应中断,且需处理编译期异常
    park/unpark 挂起:指定当前线程挂起

    唤醒:精确唤醒指定的 1 个线程

    注:虽然唤醒可指定某线程,但挂起操作只会针对当前线程生效,因为当前线程并不了解被挂起线程的真实状态,如果一旦可操控,势必会带来不可预期的安全问题
    unpark操作可发生在park之前,但仅会生效一次;例如针对线程A首先执行了2次unpark操作,然后对A第1次执行park操作时不会有阻塞,但第2次执行park时会进入等待 响应中断,但不抛出异常,发生中断后,park()方法会自动结束,通过Thread.interrupted()来判断是中断还是unpark()导致的
posted @ 2021-03-13 23:15  昔久  阅读(1622)  评论(6编辑  收藏  举报