并发 1

基本的进程线程概念

系统中的进程线程模型是这样的:

进程从操作系统获得基本的内存空间,所有的线程共享着进程的内存地址空间。当然,每个线程也会拥有自己私有的内存地址范围,其他线程不能访问。 由于所有的线程共享进程的内存地址空间,所以线程间的通信就容易的多,通过共享进程级全局变量即可实现。

JAVA 对线程概念的抽象

JAVA API 中用 Thread 这个类抽象化描述线程,线程有几种状态:

  • NEW:线程刚被创建
  • RUNNABLE:线程处于可执行状态
  • BLOCKED、WAITING:线程被阻塞,具体区别后面说
  • TERMINATED:线程执行结束,被终止

其中 RUNNABLE 表示的是线程可执行,但不代表线程一定在获取 CPU 执行中,可能由于时间片使用结束而等待系统的重新调度。BLOCKED、WAITING 都是由于线程执行过程中缺少某些条件而暂时阻塞,一旦它们等待的条件满足时,它们将回到 RUNNABLE 状态重新竞争 CPU。 此外,Thread 类中还有一些属性用于描述一个线程对象:

  • private long tid:线程的序号
  • private volatile char name[]:线程的名称
  • private int priority:线程的优先级
  • private boolean daemon = false:是否是守护线程
  • private Runnable target:该线程需要执行的方法

其中,tid 是一个自增的字段,每创建一个新线程,这个 id 都会自增一。优先级取值范围,从一到十,数值越大,优先级越高,默认值为五。

Runnable 是一个接口,它抽象化了一个线程的执行流,定义如下:

public interface Runnable {
    public abstract void run();
}
复制代码

通过重写 run 方法,你也就指明了你的线程在得到 CPU 之后执行指令的起点。我们一般会在构造 Thread 实例的时候传入这个参数。

创建并启动一个线程

创建一个线程基本上有两种方式,一是通过传入 Runnable 实现类,二是直接重写 Thread 类的 run 方法。

几个常用的方法

public static native void sleep(long millis) 这是一个本地方法,用于阻塞当前线程指定毫秒时长。

public synchronized void start() 为什么我通过重写 Runnable 的 run 方法指定了线程的工作,但却是通过 start 方法来启动线程的? 那是因为,启动一个线程不仅仅是给定一个指令开始入口即可,操作系统还需要在进程的共享内存空间中划分一部分作为线程的私有资源,创建程序计数器,栈等资源,最终才会去调用 run 方法。

public void interrupt() 这个方法用于中断当前线程,当然线程的不同状态应对中断的方式也是不同的。

public final synchronized void join(long millis) 这个方法一般在其他线程中进行调用,指明当前线程需要阻塞在当前位置,等待目标线程所有指令全部执行完毕。例如:

Thread thread = new MyThreadT();
thread.start();

thread.join();

System.out.println("i am the main thread");
复制代码

正常情况下,主函数的打印语句会在 MyThreadT 线程 run 方法执行前执行,而 join 语句则指明 main 线程必须阻塞直到 MyThreadT 执行结束。

多线程带来的一些问题

竞态条件

当多个线程同时访问并修改同一个对象,该对象最终的值往往不如预期。

内存可见性

内存可见性是指,某些情况下,线程对于一些资源变量的修改并不会立马刷新到内存中,而是暂时存放在缓存,寄存器中。这导致的最直接的问题就是,对共享变量的修改,另一个线程看不到。

Synchronized 关键字

使用它修饰需要被并发处理的代码块、方法或字段属性,虚拟机自动为它加锁和释放锁,并将不能获得锁的线程阻塞在相应的阻塞队列上。

一百个线程随机地为 count 加一,由于自增操作非原子性,多线程之间不正常的访问导致 count 最终的值不确定,始终得不到预期的结果。使用 synchronized 即刻就能解决。

我们的 JAVA 中,对于每个对象都有一把『内置锁』,而 synchronized 中的代码在被线程执行之前,会去尝试获取一个对象的锁,如果成功,就进入并顺利执行代码,否则将会被阻塞在该对象上。

synchronized 除了可以修饰代码块,还可以直接修饰在方法上。

public synchronized void addCount(){......}
public static synchronized void addCount(){......}
复制代码

这是两种不同的使用方式,前一种是使用 synchronized 修饰的实例方法,那么 synchronized 使用的就是当前方法调用时所属的那个实例的『内置锁』。也就是说,addCount 方法调用前会去尝试获取调用实例对象的锁。 而后一种 addCount 方法是一个静态方法,所以 synchronized 使用的就是 addCount 所属的类对象的锁。

基本实现原理

public class TestAxiom {
    private int count;

    @Test
    public void test() throws InterruptedException {
        synchronized (this){
            count++;
        }
    }
}
复制代码

使用 synchronized 修饰代码块,保护 count++ 操作。现在我们反编译一下:

可以看到,在执行 count++ 指令之前,编译器加了一条 monitorenter 指令,count++ 指令执行结束时又加了一条 monitorexit 指令。准确意义上来说,这就是两条加锁的释放锁的指令。

除此之外,我们的 synchronized 方法在反编译后并没有这两条指令,但是编译器却在方法表的 flags 属性中设置了一个标志位 ACC_SYNCHRONIZED。 这样,每个线程在调用该方法之前都会检查这个状态位是否为 1,如果状态为 1 说明这是一个同步方法,需要首先执行 monitorenter 指令去尝试获取当前实例对象的内置锁,并在方法执行结束执行 monitorexit 指令去释放锁。

Java 中一个对象主要由以下三种类型数据组成:

  • 对象头:也称 Mark Word,主要存储的对象的 hash 值以及相关锁信息。
  • 实例数据:保存的当前对象的数据,包括父类属性信息等。
  • 填充数据:这部分是应 JVM 要求,每个对象的起始地址必须是 8 的倍数,所以如果当前对象不足 8 的倍数字节时用于字节填充。

我们的『内置锁』在对象头里面,而 Mark Word 的一个基本结构是这样的:

其中,每把锁会有一个标志位用于区分锁类型,和一个指向锁记录的指针,也就是说锁指针会关联另一种结构,Monitor Record。

Owner 字段存储的是拥有当前锁的线程唯一标识号,当某个线程拥有了该锁之后就会把自己的线程号写入这个字段中。如果某个线程发现这里的 Owner 字段不是 null 也不是自己的线程号,那么它将会被阻塞在 Monitor 的阻塞队列上直至某个线程走出同步代码块并发起唤醒操作。 总结一下,被 synchronized 修饰的代码块或者方法在编译器会被额外插入两条指令,monitorenter 会去检查对象头锁信息,对应到一个 Monitor 结构,如果该结构的 Owner 字段已经被占用了,那么当前线程将会被阻塞在 Monitor 的一个阻塞队列上,直到占有锁的线程释放了锁并唤起一波新的锁竞争。

synchronized 的几个特性

可重入性

内存可见性

某种意义上,synchronized 是可以解决这类内存可见性问题的,修改代码如下:

主线程先获得 obj 的内置锁,然后启动 ThreadTwo 线程,该线程由于获取不到 obj 的锁而被阻塞,也就是它知道已经有其他线程在操作共享变量,所以等到自己获得锁的时候一定要从内存重新读一下共享变量。 而我们的主线程会在释放锁的时候将私有工作内存中所有的全局变量的值刷新到内存空间,这样其实就实现了多线程之间的内存可见性。 当然有一点大家要注意,synchronized 修饰的代码块会在释放锁的时候刷新自己更改过的全局变量,但是另一个线程要想看见,必须也从内存中重新读才行。而一般情况下,不是你加了 synchronized 线程就会从内存中读数据的,而只有它在竞争某把锁失败后,得知有其他线程正在修改共享变量,这样的前提下等到自己拥有锁之后才会重新去刷内存数据。 你也可以试试,让 ThreadTwo 线程不去竞争 obj 这把锁,而随便给它一个对象,结果依然会是死循环,flag 的值只会是 ThreadTwo 刚启动时从内存读入的初始数据的缓存版。 但是说实话,解决内存可见性而使用 synchronized 代价太高,需要加锁和释放锁,甚至还需要阻塞和唤醒线程,我们一般使用关键字 volatile 直接修饰在变量上就可以了,这样对于该变量的读取和修改都是直接映射内存的,不经过线程本地私有工作内存的。

线程间的协作机制

synchronized 这个关键字,通过它可以基本实现线程间在临界区对临界资源正确的访问与修改。但是,它依赖一个 Java 对象内置锁,某个时刻只能由一个线程占有该锁,其他试图占有的线程都得阻塞在对象的阻塞队列上。

但实际上还有一种情况也是存在的,如果某个线程获得了锁但在执行过程中由于某些条件的缺失,比如数据库查询的资源还未到来,磁盘读取指令的数据未返回等,这种情况下,让线程依然占有 CPU 等待是一种资源上的浪费。 所以,每个对象上也存在一个等待队列,这个队列上阻塞了所有获得锁并处于运行期间缺失某些条件的线程,所以整个对象的锁与队列状况是这样的。

Entry Set 中阻塞了所有试图获得当前对象锁而失败的线程,Wait Set 中阻塞了所有在获得锁运行期间由于缺失某些条件而交出 CPU 的线程集合。

而当某个现场称等待的条件满足了,就会被移除等待队列进入阻塞队列重新竞争锁资源。

wait/notify 方法

Object 类中有几个方法我们虽然不常使用,但是确实线程协作的核心方法,我们通过这几个方法控制线程间协作。

public final native void wait(long timeout)

public final void wait()

public final native void notify();

public final native void notifyAll();
复制代码

wait 类方法用于阻塞当前线程,将当前线程挂载进 Wait Set 队列,notify 类方法用于释放一个或多个处于等待队列中的线程。 所以,这两个方法主要是操作对象的等待队列,也即是将那些获得锁但是运行期间缺乏继续执行的条件的线程阻塞和释放的操作。 但是有一个前提大家需要注意,wait 和 notify 操作的是对象内置锁的等待队列,也就是说,必须在获得对象内置锁的前提下才能阻塞和释放等待队列上的线程。简单来说,这两个方法的只能在 synchronized 修饰的代码块内部进行调用。

public class Test {
    private static Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(){
            @Override
            public void run(){
                synchronized (lock){
                    try {
                        lock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };
        Thread thread2 = new Thread(){
            @Override
            public void run(){
                synchronized (lock){
                    System.out.println("hello");
                }
            }
        };
        thread1.start();
        thread2.start();

        Thread.sleep(2000);

        System.out.println(thread1.getState());
        System.out.println(thread2.getState());
    }
}
复制代码
hello
WAITING
TERMINATED
复制代码

可以看到,程序是没有正常结束的,也就是说,有线程还未正常退出。线程一优先启动于线程二,所以它将先获得 lock 锁,接着调用 wait 方法将自己阻塞在 lock 对象的等待队列上,并释放锁交出 CPU。 线程二启动时可能由于线程一依然占有锁而阻塞,但当线程一释放锁以后,线程二将获得锁并执行打印语句,随后同步方法结束并释放锁。 此时,线程一依然阻塞在 lock 对象的等待队列上,所以整个程序没有正常退出。 演示这么一段程序的意义是什么呢?就是想告诉大家,虽然阻塞队列和等待队列上的线程都不能得到 CPU 正常执行指令,但是它们却属于两种不同的状态,阻塞队列上的线程在得知锁已经释放后将公平竞争锁资源,而等待队列上的线程则必须有其他线程通过调用 notify 方法通知并移出等待队列进入阻塞队列,重新竞争锁资源。

如果在Thread2的run方法里面添加lock.notifyAll();方法,结果为

hello
TERMINATED
TERMINATED
复制代码

相关方法的实现

sleep 方法用于阻塞当前线程指定时长,线程状态随即变成 TIMED_WAITING,但区别于 wait 方法。两者都是让出 CPU,但是 sleep 方法不会释放当前持有的锁。 也就是说,sleep 方法不是用于线程间同步协作的方法,它只是让线程暂时交出 CPU,暂停运行一段时间,时间到了将由系统调度分配 CPU 继续执行。

join 方法用于实现两个线程之间相互等待的一个操作,看段代码:

public void testJoin() throws InterruptedException {
    Thread thread = new Thread(){
        @Override
        public void run(){
            for (int i=0; i<1000; i++)
                System.out.println(i);
        }
    };
    thread.start();

    thread.join();
    System.out.println("main thread finished.....");
}
复制代码

抛开 join 方法不谈,main 线程中的打印方法一定是先执行的,而实际上这段程序会在线程 thread 执行完成之后才执行主线程的打印方法。

方法的核心就是调用 wait(delay) 阻塞当前线程,当线程被唤醒计算从进入方法到当前时间共经过了多久。 接着比较 millis 和 这个 now,如果 millis 小于 now 说明,说明等待时间已经到了,可以退出方法返回了。否则则说明线程提前被唤醒,需要继续等待。 需要注意的是,既然是调用的 wait 方法,那么等待的线程必然是需要释放持有的当前对象内置锁的,这区别于 sleep 方法。

一个典型的线程同步问题

生产者不停生产产品到仓库中直到仓库满,消费者不停的从仓库中取出产品直到仓库为空。如果生产者发现仓库已经满了,就不能继续生产产品,而消费者如果发现仓库为空,就不能从仓库中取出产品。

public class Repository {
    private List<Integer> list = new ArrayList<>();
    private int limit = 10;  //设置仓库容量上限

    public synchronized void addGoods(int count) throws InterruptedException {
        while(list.size() == limit){
            //达到仓库上限,不能继续生产
            wait();
        }
        list.add(count);
        System.out.println("生产者生产产品:" + count);
        //通知所有的消费者
        notifyAll();
    }

    public synchronized void removeGoods() throws InterruptedException {
        while(list.size() <= 0){
            //仓库中没有产品
            wait();
        }

        int res = list.get(0);
        list.remove(0);
        System.out.println("消费者消费产品:" + res);
        //通知所有的生产者
        notifyAll();
    }
}
复制代码

写一个仓库类,该类提供两个方法供外部调用,一个是往仓库放产品,如果仓库满了则阻塞到仓库对象的等待队列上,一个是从仓库中取出产品,如果仓库为空则阻塞在仓库的等待队列上。

public class Producer extends Thread{
    Repository repository = null;

    public Producer(Repository p){
        this.repository = p;
    }

    @Override
    public void run(){
        int count = 1;
        while(true){
            try {
                Thread.sleep((long) (Math.random() * 500));
                repository.addGoods(count++);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
复制代码

定义一个生产者类,生产者随机的向仓库添加产品。如果没有能成功的添加,会被阻塞在循环里。

public class Customer extends Thread{
    Repository repository = null;

    public Customer(Repository p){
        this.repository = p;
    }

    @Override
    public void run(){
        while(true){
            try {
                Thread.sleep((long) (Math.random() * 500));
                repository.removeGoods();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
复制代码

定义一个消费者类,消费者类随机的从仓库中取一个产品。如果没有成功的取出一个产品,同样会被阻塞在循环里。

public void testProducerAndCustomer() {
    Repository repository = new Repository();
    Thread producer = new Producer(repository);
    Thread consumer = new Customer(repository);

    producer.start();
    consumer.start();

    producer.join();
    consumer.join();
    System.out.println("main thread finished..");
}
复制代码
生产者生产产品:1
消费者消费产品:1
生产者生产产品:2
消费者消费产品:2
生产者生产产品:3
消费者消费产品:3
。。。。。
。。。。。
消费者消费产品:17
生产者生产产品:21
消费者消费产品:18
生产者生产产品:22
。。。。。。
。。。。。。
复制代码

仔细观察,你会发现,消费者者永远不会消费一个不存在的产品,消费的一定是生产者生产的产品。刚开始可能是生产者生产一个产品,消费者消费一个产品,而一旦消费者线程执行的速度超过了生产者,必然会由于仓库容量为空而被阻塞。 生产者线程的执行速度可以超过消费者线程,而消费者线程的执行速度如果一直超过生产者就会导致仓库容量为空而致使自己被阻塞。 总结一下,synchronized 修饰的代码块是直接使用的对象内置锁的阻塞队列,线程获取不到锁自然被阻塞在该队列上,而 wait/notify 则是我们手动的控制等待队列的入队和出队操作。但本质上都是利用的对象内置锁的两个队列。

偏向锁,轻量级锁, 重量级锁

Java 对象头
  • 字宽(Word): 内存大小的单位概念, 对于 32 位处理器 1 Word = 4 Bytes, 64 位处理器 1 Word = 8 Bytes
  • 每一个 Java 对象都至少占用 2 个字宽的内存(数组类型占用3个字宽)。
    • 第一个字宽也被称为对象头Mark Word。 对象头包含了多种不同的信息, 其中就包含对象锁相关的信息。
    • 第二个字宽是指向定义该对象类信息(class metadata)的指针
  • 非数组类型的对象头的结构如下图

  • 说明:
    • MarkWord 中包含对象 hashCode 的那种无锁状态是偏向机制被禁用时, 分配出来的无锁对象MarkWord 起始状态
    • 偏向机制被启用时,分配出来的对象状态是 ThreadId|Epoch|age|1|01, ThreadId 为空时标识对象尚未偏向于任何一个线程, ThreadId 不为空时, 对象既可能处于偏向特定线程的状态, 也有可能处于已经被特定线程占用完毕释放的状态, 需结合 Epoch 和其他信息判断对象是否允许再偏向(rebias)。

CAS 指令
  • CAS (Compare And Swap) 指令是一个CPU层级的原子性操作指令。 在 Intel 处理器中, 其汇编指令为 cmpxchg。
  • 该指令概念上存在 3 个参数, 第一个参数【目标地址】, 第二个参数【值1】, 第三个参数【值2】, 指令会比较【目标地址存储的内容】和 【值1】 是否一致, 如果一致, 则将【值 2】 填写到【目标地址】, 其语义可以用如下的伪代码表示。
function cas(p , old , new ) returns bool {
    if *p ≠ old { // *p 表示指针p所指向的内存地址
        return false
    }
    *p ← new
    return true
}
复制代码

注意: 该指令是是原子性的, 也就是说 CPU 执行该指令时, 是不会被中断执行其他指令的

“CAS”实现的"无锁"算法常见误区
  • 误区一: 通过简单应用 “比较后再赋值” 的操作即可轻松实现很多无锁算法
    • CAS 指令的一个不可忽略的特征是原子性。 在 CPU 层面, CAS 指令的执行是有原子性语义保证的, 如果 CAS 操作放在应用层面来实现, 则需要我们自行保证其原子性。 否则就会发生如下描述的问题:
// 下列的函数如果不是线程互斥的, 是错误的 CAS 实现
function cas( p , old , new) returns bool {
    if *p ≠ old { // 此处的比较操作进行时, 可以同时有多个线程通过该判断
        return false
    }
    *p ← new // 多个线程的赋值操作会相互覆盖, 造成程序逻辑的错误
    return true
}
复制代码
  • 误区二: CAS 操作的 ABA 问题
    • 大部分网络博文对 ABA 问题的常见描述是: 应用 CAS 操作时, 目标地址的值刚开始为 A, 工作线程/进程 读取后, 进行了一系列运算, 计算得出了新值 C, 在此期间, 目标地址的值被其他线程已经进行了不止一次修改, 其值已经从 A 被改为 B , 又改回 A, 此时便会发生同步问题。
    • 上面的描述是其实是错误的, 思考一下就会发现, 如果工作线程的操作目的是将目标地址的值从 A 改为 C, 那么即便在这期间目标地址的值经过了其他线程或进程的多次修改, 其语义依旧是正确的。
    • 例如目前要将某银行账号的余额扣除 50, 通过 CAS 保证同步 :
      • 首先读取原有余额为 100 ,
      • 计算余额应该赋值为 100 - 50 = 50
      • 此时该线程被挂起, 该账户同时又发生了转入 150 和转出 150 的操作, 余额经历了 100 -》250 -》100 的变动
      • 线程被唤醒, 进行 CAS 赋值操作 cas(p, 100, 50) , 正常得以执行。
      • 该账户的余额依旧是正确的
    • 通过上述例子就可以发现, ABA 的问题并不在于多次修改。 查阅一下 CAS 的 wiki 解释, 就会发现, ABA 真正的问题是, 假如目标地址的内容被多次修改以后, 虽然从二进制上来看是依旧是 A, 但是其语义已经不是 A 。例如, 发生了整数溢出, 内存回收等等。
栈帧(Stack Frame) 的概念
  • 每个线程都有自己独立的内存空间, 栈帧就是其中的一部分。里面可以存储仅属于该线程的一些信息。
轻量级加锁的过程

(1)在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。

(2)拷贝对象头中的Mark Word复制到锁记录中。这时候线程堆栈与对象头的状态如图2.1所示

(3)拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤(4),否则执行步骤(5)。

(4)如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图2.2所示。

(5)如果这个更新操作失败了,说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁

重量级加锁的过程
  • 轻量级锁在向重量级锁膨胀的过程中, 一个操作系统的互斥量(mutex)和条件变量( condition variable )会和这个被锁的对象关联起来。
  • 具体而言, 在锁膨胀时, 被锁对象的 markword 会被通过 CAS 操作尝试更新为一个数据结构的指针, 这个数据结构中进一步包含了指向操作系统互斥量(mutex) 和 条件变量(condition variable) 的指针

synchronized 关键字之锁的升级(偏向锁->轻量级锁->重量级锁)

synchronized 代码块是由一对 monitorenter/moniterexit 字节码指令实现, monitor 是其同步实现的基础, Java SE1.6 为了改善性能, 使得 JVM 会根据竞争情况, 使用如下 3 种不同的锁机制

  • 偏向锁(Biased Lock )
  • 轻量级锁( Lightweight Lock)
  • 重量级锁(Heavyweight Lock)

上述这三种机制的切换是根据竞争激烈程度进行的, 在几乎无竞争的条件下, 会使用偏向锁, 在轻度竞争的条件下, 会由偏向锁升级为轻量级锁, 在重度竞争的情况下, 会升级到重量级锁。

下图展现了一个对象在创建(allocate) 后, 根据偏斜锁机制是否打开, 对象 MarkWord 状态以不同方式转换的过程

无锁 -> 偏向锁

从上图可以看到 , 偏向锁的获取方式是将对象头的 MarkWord 部分中, 标记上线程ID, 以表示哪一个线程获得了偏向锁。 具体的赋值逻辑如下:

  • 首先读取目标对象的 MarkWord, 判断是否处于可偏向的状态(如下图)

  • 如果为可偏向状态, 则尝试用 CAS 操作, 将自己的线程 ID 写入MarkWord

    • 如果 CAS 操作成功(状态转变为下图), 则认为已经获取到该对象的偏向锁, 执行同步块代码 。 注意, age 后面的标志位中的值并没有变化, 这点之后会用到
    • 补充: 一个线程在执行完同步代码块以后, 并不会尝试将 MarkWord 中的 thread ID 赋回原值 。这样做的好处是: 如果该线程需要再次对这个对象加锁,而这个对象之前一直没有被其他线程尝试获取过锁,依旧停留在可偏向的状态下, 即可在不修改对象头的情况下, 直接认为偏向成功。

- 如果 CAS 操作失败, 则说明, 有另外一个线程 Thread B 抢先获取了偏向锁。 这种状态说明该对象的竞争比较激烈, 此时需要撤销 Thread B 获得的偏向锁,将 Thread B 持有的锁升级为轻量级锁。 该操作需要等待全局安全点 JVM safepoint ( 此时间点, 没有线程在执行字节码) 。
复制代码
  • 如果是已偏向状态, 则检测 MarkWord 中存储的 thread ID 是否等于当前 thread ID 。

    • 如果相等, 则证明本线程已经获取到偏向锁, 可以直接继续执行同步代码块
    • 如果不等, 则证明该对象目前偏向于其他线程, 需要撤销偏向锁

偏向锁的 撤销(revoke) 是一个很特殊的操作, 为了执行撤销操作, 需要等待全局安全点(Safe Point), 此时间点所有的工作线程都停止了字节码的执行。

偏向锁的撤销(Revoke)

偏向锁的撤销(Revoke) 操作并不是将对象恢复到无锁可偏向的状态, 而是在偏向锁的获取过程中, 发现了竞争时, 直接将一个被偏向的对象“升级到” 被加了轻量级锁的状态。 这个操作的具体完成方式如下:

  • 在偏向锁 CAS 更新操作失败以后, 等待到达全局安全点。
    • 通过 MarkWord 中已经存在的 Thread Id 找到成功获取了偏向锁的那个线程, 然后在该线程的栈帧中补充上轻量级加锁时, 会保存的锁记录(Lock Record), 然后将被获取了偏向锁对象的 MarkWord 更新为指向这条锁记录的指针。
    • 至此, 锁撤销操作完成, 阻塞在安全点的线程可以继续执行。

偏向锁的批量再偏向(Bulk Rebias)机制

偏向锁这个机制很特殊, 别的锁在执行完同步代码块后, 都会有释放锁的操作, 而偏向锁并没有直观意义上的“释放锁”操作。

那么作为开发人员, 很自然会产生的一个问题就是, 如果一个对象先偏向于某个线程, 执行完同步代码后, 另一个线程就不能直接重新获得偏向锁吗? 答案是可以, JVM 提供了批量再偏向机制(Bulk Rebias)机制

该机制的主要工作原理如下:

  • 引入一个概念 epoch, 其本质是一个时间戳 , 代表了偏向锁的有效性
  • 从前文描述的对象头结构中可以看到, epoch 存储在可偏向对象的 MarkWord 中。
  • 除了对象中的 epoch, 对象所属的类 class 信息中, 也会保存一个 epoch 值
  • 每当遇到一个全局安全点时, 如果要对 class C 进行批量再偏向, 则首先对 class C 中保存的 epoch 进行增加操作, 得到一个新的 epoch_new
  • 然后扫描所有持有 class C 实例的线程栈, 根据线程栈的信息判断出该线程是否锁定了该对象, 仅将 epoch_new 的值赋给被锁定的对象中。
  • 退出安全点后, 当有线程需要尝试获取偏向锁时, 直接检查 class C 中存储的 epoch 值是否与目标对象中存储的 epoch 值相等, 如果不相等, 则说明该对象的偏向锁已经无效了, 可以尝试对此对象重新进行偏向操作。

偏向锁 -> 轻量级锁

从之前的描述中可以看到, 存在超过一个线程竞争某一个对象时, 会发生偏向锁的撤销操作。 有趣的是, 偏向锁撤销后, 对象可能处于两种状态。

一种是不可偏向的无锁状态, 如下图(之所以不允许偏向, 是因为已经检测到了多于一个线程的竞争, 升级到了轻量级锁的机制)

另一种是不可偏向的已锁 ( 轻量级锁) 状态

之所以会出现上述两种状态, 是因为偏向锁不存在解锁的操作, 只有撤销操作。 触发撤销操作时:

  • 原来已经获取了偏向锁的线程可能已经执行完了同步代码块, 使得对象处于 “闲置状态”,相当于原有的偏向锁已经过期无效了。此时该对象就应该被直接转换为不可偏向的无锁状态。
  • 原来已经获取了偏向锁的线程也可能尚未执行完同步代码块, 偏向锁依旧有效, 此时对象就应该被转换为被轻量级加锁的状态

轻量级加锁过程:

首先根据标志位判断出对象状态处于不可偏向的无锁状态( 如下图)

在当前线程的栈桢(Stack Frame)中创建用于存储锁记录(lock record)的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。如果在此过程中发现,

然后线程尝试使用 CAS 操作将对象头中的 Mark Word 替换为指向锁记录的指针。

  • 如果成功,当前线程获得锁
  • 如果失败,表示该对象已经被加锁了, 先进行自旋操作, 再次尝试 CAS 争抢, 如果仍未争抢到, 则进一步升级锁至重量级锁。

注意: 下图中第一个标绿 MarkWord 的起始状态是错误的, 正确的起始状态应该是 ThreadId(空)|age|1|01, HashCode|age|0|01 是偏向锁未被启用时, 分配对象后的状态

重量级锁

重量级锁依赖于操作系统的互斥量(mutex) 实现,该操作会导致进程从用户态与内核态之间的切换, 是一个开销较大的操作。

线程的中断

中断,其实指的就是程序在执行过程中,发生了某些非正常的事件指示当前进程不能继续执行了,应当得到暂停或终止,而通知正在执行的进程暂停执行的这个操作就叫『中断』。

中断源

如果按照中断事件类型来分,大致上有以下几种类型的中断事件类型:

  • 机器故障中断事件。往往是电源故障、硬件设备连接故障等
  • 程序性中断事件。这种大多是我们的程序代码逻辑问题,导致的例如内存溢出、除数为零等问题
  • 外部中断事件。主要是时钟中断
  • 输入输出中断事件。设备出错或是传输结束

每一种类型的中断事件都对应一位二进制的比特位,系统中也对应一个中断寄存器用于保存当前系统所遇到的所有中断事件,1 表示该类型的中断事件发生,0 表示未发生。 中断操作主要分为两种方式,一种叫『抢占式中断』,一种叫『主动式中断』。前者就是在发生中断时,强制剥夺线程的 CPU,后者是在正在执行的线程中断位上标记一下,具体什么时候中断由线程自己来决定。 当线程发现自己有中断事件时,会根据中断事件的类型去对应相应的中断处理程序来处理该中断事件。

1、电源故障(掉电)

首先,当我们的系统丢失电源时,系统硬设备是能保证继续工作一小段时间的。这也是为什么你的用浏览器浏览这好几个标签,突然关机了,开机后打开浏览器会提示你上次异常关闭,问你是否恢复的原因。 而我们的中断处理程序首先会将当前所有寄存器中的数据经由主存保存到磁盘,接着停止 CPU 的运行,直至停机。 下次开机时,中断处理程序会从磁盘加载中断前的寄存器数据,恢复现场。

2、程序逻辑中断

当我们的 CPU 执行除运算时遇到除数为零,将产生一个中断事件,对应的处理程序会简单的将错误类型及信息进行一个返回。 内存溢出异常也是一样的处理。

中断线程

public void interrupt()

public static boolean interrupted()

public boolean isInterrupted()
复制代码

interrupt 方法表示中断当前线程,仅仅设置一下线程的中断标记位。

interrupted 是一个静态的方法,它将返回当前线程的中断位是否被标记,如果是则返回 true 并清空中断标记位,否则返回 false。

isInterrupted 方法功能是类似于 interrupted 方法的,只不过无论当前线程是否被中断了,都不会清空中断标志位。

public void test() {
    Thread thread = new Thread(){
        @Override
        public void run(){
            for (int i=0; i<50000; i++){
                System.out.println("i=" + i);
            }
        }
    };
    thread.start();
    thread.interrupt();
        
    thread.join();
}
复制代码

这样一段代码,我们创建一个线程,该线程启动后打印 50000 个数字,但是我们的主线程中又会去中断该线程。 抢断式中断方式下,thread 线程可能只打印了几个数字,甚至还未开始执行打印操作就被剥夺了 CPU,提前结束生命周期。 而我们的 Java 中不推荐使用抢断式中断,倡导「一个线程的生命不应该由其他线程终止,应当由它自己选择是否停止」。所以,这段程序会成功打印 50000 个数字,即便 thread 线程的中断标记位已经被标记。

简单修改下,我们的代码即能响应中断:

每一次打印前都去检查一下自己的中断标记位是否为 true,判断自己是否被中断以采取相应的处理操作。

但是这仅仅是线程处于 RUNNABLE 状态下对于中断请求的响应情况,下面我们具体看看线程的其他状态下,面对中断请求的响应措施。

线程对于中断的响应

  • RUNNABLE

状态为 RUNNABLE 的线程是拥有 CPU 正在运行的线程,我们的 interrupt 方法仅仅会设置一下该线程的中断标志位,不会做任何其他的操作,关于你是否响应此中断,由你自己决定。

  • WAITING WAITING 状态是线程在获得锁的前提下,正常运行过程中由于缺失一些条件而被迫释放锁,交出 CPU,阻塞到等待队列上,等待别人唤醒的一个状态。 这个状态下的线程一旦被别人 interrupt 中断,将直接抛出异常 java.lang.InterruptedException。我们看一段代码:
public void test1() {
    Object obj = new Object();
    Thread thread = new Thread(){
        @Override
        public void run(){
            synchronized (obj){
                try {
                    obj.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    };

    thread.start();
    //主线程等待 thread 线程获取 obj 对象锁并阻塞自己到等待队列
    Thread.sleep(2000);
    thread.interrupt();
}
复制代码

程序直接抛出异常,并清空中断标志位。

你可以思考一下,一个 WAITING 状态的线程被中断为什么要抛出一个异常? 其实还是那个理念,「任何线程都没有权利终止另一个线程的生命」,一个正在 WAITING 中的线程由于不具有 CPU 的使用权,你中断它,它永远都不会知道自己被中断了直到自己重新竞争到了锁并得到运行。 那么,我们的主线程在调用 interrupt 方法中断一个线程,当发现它的状态为 WAITING 时,将唤醒它并更改指令寄存器的值以指向异常代码块,期待你自己来处理这个中断。 这也是为什么 wait、sleep、join 这些方法必须处理一个受检查的异常 InterruptException 的原因,因为这些方法会阻塞线程,而如果在阻塞期间收到中断,你也应当提供中断的处理逻辑。

  • BLOCKED BLOCKED 状态的线程往往是竞争某个锁失败,而阻塞在某个对象的阻塞队列上的线程。

这个状态的线程和 RUNNABLE 状态的线程一样,对于中断请求不做额外响应,仅仅设置一下中断标志位,具体什么时候处理中断需要程序自己去循环检测判断。

  • NEW/TERMINATE 对于这两个状态的线程进行中断请求,目标线程什么也不会做,就连中断标志位也不会被设置,因为 Java 认为,一个还未启动的线程和一个已经结束的线程,对于他们的中断是毫无意义的。

CAS 无锁式同步机制

计算机系统中,CPU 和内存之间是通过总线进行通信的,当某个线程占有 CPU 执行指令的时候,会尽可能的将一些需要从内存中访问的变量缓存在自己的高速缓存区中,而修改也不会立即映射到内存。 而此时,其他线程将看不到内存中该变量的任何改动,这就是我们说的内存可见性问题。

其一是使用关键字 volatile 修饰共享的全局变量,而 volatile 的实现原理大致分两个步骤,任何对于该变量的修改操作都会由虚拟机追加一条指令立马将该变量所在缓存区中的值回写内存,接着将失效该变量在其他 CPU 缓存区的引用。也就意味着,其他 CPU 如果再想要使用该变量,缓存中是没有的,进而逼迫去访问内存拿最新的数据。

其二是使用关键字 synchronized 并借助对象内置锁实现数据一致性,主要思路是,如果一个线程因为竞争某个锁失败而被阻塞了,那么它就认为别的线程正在工作,很可能会改了某些共享变量的数据,进而在获得锁后第一时间重新刷内存中的数据,同时一个线程走出同步代码块之前会同步数据到内存。

volatile 的局限性

public class MainTest {
    private static volatile int count;

    @Test
    public void testVolatile() throws InterruptedException {
        Thread1[] thread1s = new Thread1[100];
        for (int i = 0; i < 100; i++){
            thread1s[i] = new Thread1();
            thread1s[i].start();
        }

        for (int j = 0; j < 100; j++){
            thread1s[j].join();
        }
        System.out.println(count);
    }
    //每个线程随机自增 count
    private class Thread1 extends Thread{
        @Override
        public void run(){
            try {
                Thread.sleep((long) (Math.random() * 500));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            count++;
        }
    }
}
复制代码

我们将变量 count 使用 volatile 进行修饰,然后创建一百个线程并启动,按照我们之前的理解,变量 count 的值一旦被修改就可以被其他线程立马看到,不会缓存在自己的工作内存。但是结果却不是这样。

多次运行,结果不尽相同

94

96

98
复制代码

volatile 会在变量值被修改后回写内存并失效其他 CPU 缓存中该变量的引用迫使其他线程从主存中重新去获取该变量的值。 但是 count++ 这个操作并不是原子操作,之前我们说过这一点,这个操作会使得 CPU 做以下几件事情:

从 CPU 缓存读出变量的值放入寄存器 A 中 为 count 加一并将值保存在另一个寄存器 B 中 将寄存器 B 中的数据写到缓存并通过缓存锁回写内存

而如果第一步刚执行结束,或第二步刚执行结束,但没有执行第三步的时候,其他的某个线程更改了该变量的值并失效了当前 CPU 中缓存中该变量的引用,那么第三步会由于缓存失效而先去内存中读一个值过来,然后用寄存器 B 中的值覆盖缓存并刷到内存中。 这就意味着,在此之前其他线程的修改被覆盖,进而我们得不到我们预期的结果。结论就是,volatile 关键字具有可见性而不具有原子性。

原子类型变量

  • AtomicBoolean:对应的 Boolean 类型的原子类型
  • AtomicInteger:对应的 Integer 类型的原子类型
  • AtomicLong:类似
  • AtomicIntegerArray:对应的数组类型
  • AtomicLongArray:类似
  • AtomicReference:对应的引用类型的原子类型
  • AtomicIntegerFieldUpdater:字段更新类型

AtomicInteger 相关实现

内部定义了一个 int 类型的变量 value,并且 value 修饰为 volatile,表示 value 这个字段值的任何修改都对其他线程立即可见。

而构造函数允许你传入一个初始的 value 数值,不传的话就会导致 value 的值为零。

public final int getAndIncrement() {
    return unsafe.getAndAddInt(this, valueOffset, 1);
}
复制代码

这个方法就是原子的「i++」操作,我们跟进去看:

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}
复制代码

几个参数简单说一下,var1 是我们的 AtomicInteger 实例引用,var2 是一个字段偏移量,通过它我们可以定位到其中的 value 字段。var4 这里固定为一。 代码的逻辑也是简单的,取出内部 value 字段的值并暂存在变量 value5 中,然后再次判断,如果 value 字段的值依然等于 value5,那么将原子操作式将 value 修改为 value4 + value5,本质上就是加一。 否则,说明在当前线程上次访问后,又有其他线程修改了这个 value 字段的值,于是我们重新获取这个字段的值,直到没有人修改为止并自增它。 这个 compareAndSwapInt 方法我们一般把它叫做『CAS』,底层有系统指令做支撑,是一个比较并修改的原子指令,如果值等于 A 则将它修改为 B,否则返回。

AtomicInteger 中的其余方法大致类似,都是依赖这个『CAS』方法实现的。

  • int getAndAdd(int delta):自增 delta 并获取修改之前的值
  • int incrementAndGet():自增并获取修改后的值
  • int decrementAndGet():自减并获取修改后的值
  • int addAndGet(int delta):自增 delta 并获取修改后的值
//构建一个原子类型变量 aCount
private static volatile AtomicInteger aCount = new AtomicInteger(0);
@Test
public void testAtomic() throws InterruptedException {
    Thread2[] threads = new Thread2[100];
    for (int i = 0; i < 100; i++){
        threads[i] = new Thread2();
        threads[i].start();
    }
    for (int i = 0; i < 100; i++){
        threads[i].join();
    }
    System.out.println(aCount.get());
}

private class Thread2 extends Thread{
    @Override
    public void run(){
        try {
            Thread.sleep((long) (500 * Math.random()));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //原子自增
        aCount.getAndIncrement();
    }
}
复制代码

修改后的代码无论运行多少次,总会得到结果 100 。

AtomicLong、AtomicReference 都是依赖我们这个『CAS』方法,这里不再赘述。FieldUpdater 是基于反射来原子修改变量的值。

CAS 的局限性

ABA 问题

CAS 有一个典型问题就是「ABA 问题」,我们知道 CAS 工作的基本原理是,先读取目标变量的值,然后调用原子指令判断该值是否等于我们期望的值,如果等于就认为没有被别人改过,否则视作数据脏了,重新去读变量的值。 但是问题是,如果变量 a 的值为 100,我们的 CAS 方法也读到了 100,接着来了一个线程将这个变量改为 999,之后又来一个线程再改了一下,改成 100 。而轮到我们的主线程发现 a 的值依然是 100,它视作没有人和它竞争修改 a 变量,于是修改 a 的值。 这种情况,虽然 CAS 会更新成功,但是会存在潜在的问题,中途加入的线程的操作对于后一个线程根本是不可见的。而一般的解决办法是为每一次操作加上加时间戳,CAS 不仅关注变量的原始值,还关注上一次修改时间。

循环时间长开销大

我们的 CAS 方法一般都定义在一个循环里面,直到修改成功才会退出循环,如果在某些并发量较大的情况下,变量的值始终被别的线程修改,本线程始终在循环里做判断比较旧值,效率低下。

所以说,CAS 适用于并发量不是很高的情况下,效率远远高于锁机制。

只能保证一个变量的原子操作

CAS 只能对一个变量进行原子性操作,而锁机制则不同,获得锁之后,就可以对所有的共享变量进行修改而不会发生任何问题,因为别人没有锁不能修改这些共享变量。 总结一下,锁其实是一种悲观的思想,「我认为所有人都会和我来竞争某些资源的使用,所以我得到资源之后把它锁上,用完再释放掉锁」,而 CAS 则是一种乐观的思想,「我以为只有我一个人在使用这些资源,假如有人也在使用,那我再次尝试即可」。 CAS 是以后的各种并发容器的实现基石,是一种乐观的、非阻塞式的算法,将有助于提升我们的并发性能。

显式锁原理

Synchronized 关键字结合对象的监视器,JVM 为我们提供了一种『内置锁』的语义,这种锁很简便,不需要我们关心加锁和释放锁的过程,我们只需要告诉虚拟机哪些代码块需要加锁即可,其他的细节会由编译器和虚拟机自己实现。 可以将我们的『内置锁』理解为是 JVM 的一种内置特性, 所以一个很显著的问题就是,它不支持某些高级功能的定制,比如说,我想要这个锁支持公平竞争,我想要根据不同的条件将线程阻塞在不同的队列上,我想要支持定时竞争锁,超时返回,我还想让被阻塞的线程能够响应中断请求,等等等等。 这些特殊的需求是『内置锁』满足不了的,所以在 JDK 层面又引入了『显式锁』的概念,不再由 JVM 来负责加锁和释放锁,这两个动作释放给我们程序来做,程序层面难免复杂了些,但锁灵活性提高了,可以支持更多定制功能,但要求你对锁具有更深层次的理解。

Lock 显式锁

public interface Lock {
    //获取锁,失败则阻塞
    void lock();
    //响应中断式获取锁
    void lockInterruptibly()
    //尝试一次获取锁,成功返回true,失败返回false,不会阻塞
    boolean tryLock();
    //定时尝试
    boolean tryLock(long time, TimeUnit unit)
    //释放锁
    void unlock();
    //创建一个条件队列
    Condition newCondition();
}
复制代码

Lock 定义了显式锁应该具有的最基本的方法,各个子类的实现应该具有更加复杂的能力,整个 Lock 的框架如下:

其中,显式锁的实现类主要有三个,ReentrantLock 是其最主要的实现类,ReadLock 和 WriteLock 是 ReentrantReadWriteLock 内部定义的两个内部类,他们继承自 Lock 并实现了其定义的所有方法,精细化读写分离。而 ReentrantReadWriteLock 向外提供读锁写锁。 至于 LockSupport,它提供了阻塞和唤醒一个线程的能力,当然内部也是通过 Unsafe 类继而调用操作系统底层的 API 来实现的。 AbstractQueuedSynchronizer 你可以叫它队列同步器,也可以简称它为 AQS,它是我们实现锁的一个核心,本质上就是个同步机制,记录当前占有锁的线程,每一个想要获取锁的线程都需要通过这个同步机制来判断自己是否具备占有该锁的条件,如果不具备则阻塞等待,否则将占有锁,修改标志。

ReentrantLock 的基本理解

ReentrantLock 作为 Lock 显式锁的最基本实现,也是使用最频繁的一个锁实现类。它提供了两个构造函数,用于支持公平竞争锁。

public ReentrantLock()
public ReentrantLock(boolean fair)
复制代码

默认无参的构造函数表示启用非公平锁,当然也可以通过第二个构造函数传入 fair 参数值为 true 指明启用公平锁。 公平锁和非公平锁的区别之处在于,公平锁在选择下一个占有锁的线程时,参考先到先得原则,等待时间越长的线程将具有更高的优先级。而非公平锁则无视这种原则。 两种策略各有利弊,公平策略可以保证每个线程都公平的竞争到锁,但是维护公平算法本身也是一种资源消耗,每一次锁请求的线程都直接被挂在队列的尾部,而只有队列头部的线程有资格使用锁,后面的都得排队。 那么假设这么一种情况,A 获得锁正在运行,B 尝试获得锁失败被阻塞,此时 C 也尝试获得锁,失败而阻塞,虽然 C 只需要很短运行时间,它依然需要等待 B 执行结束才有机会获得锁来运行。 非公平锁的前提下,A 执行结束,找到队列首部的 B 线程,开始上下文切换,假如此时的 C 过来竞争锁,非公平策略前提下,C 是可以获得锁的,并假设它迅速的执行结束了,当 B 线程被切换回来之后再去获取锁也不会有什么问题,结果是,C 线程在 B 线程的上下文切换过程中执行结束。显然,非公平策略下 CPU 的吞吐量是提高的。但是,非公平策略的锁可能会造成某些线程饥饿,始终得不到运行,各有利弊,适时取舍。庆幸的是,我们的显式锁支持两种模式的切换选择。

ReentrantLock 中有以下三个内部类是比较重要的:

内部类 Sync 继承自我们的 AQS 并重写了部分方法,NonfairSync 和 FairSync 是 Sync 的两个子类,分别对应公平锁和非公平锁。

为什么这么做呢?

类 Sync 中有一个 lock 方法,而公平策略下的 lock 方法和非公平策略下的 lock 方法应该具有不同的实现,所以这里并没有写死,而是交由子类去实现它。 这其实是一种典型的设计模式,『模板方法』。 关于 AQS,我们稍后做详细的分析,这里你把它理解为一个用于记录保存当前占有锁线程信息和阻塞在该锁上所有线程信息的容器即可。 接着看 ReentrantLock,你会发现,无论是 lock 方法,lockInterruptibly 方法、tryLock 或是 unlock 方法都是透传调用 sync 的相关方法,也即 AQS 中的相关方法。

任务的提交与异步执行

异步编程是一种对 CPU 资源更高效的编程方式,也是提高系统吞吐量的一个非常不错的选择。很多人会认为所谓的异步不就是多线程吗? 但实际上这句话只能说对一半,没错,异步是通过多线程来实现的,但我们 Java 中的异步编程却绝不仅仅只是多线程,它还包括对任务执行状态的监控、随时可以选择性的中断任务的执行以及获取任务执行的返回结果。 Java 的并发包下为我们提供了一整套完善的异步任务框架,包括任务的定义、任务的提交、线程的创建与任务分配、监控任务状态、取消任务等等,绝不仅仅局限于多线程的简单创建与启动。

简单介绍与使用

任务的抽象

我们使用接口 Runnable 与 Callable 抽象的描述一个任务,前者相信大家已经非常的熟悉了,后者我们见的不多,但其实也是一个很简单的接口,与 Runable 接口一样也是一个函数式接口,内部定义一个 call 方法。 相比于 Runnable,除了内部定义的方法名称不同外,call 方法还要求调用结束后返回一个结果,至于返回的结果是什么,取决于你的实现类,总的来说,两者差别不大。

任务的执行

Executor 接口抽象了任务的执行者,所有的任务都可以向这里进行提交,Executor 会负责创建线程并启动线程,执行任务。

Executor 接口的定义也是非常简单的,只有一个 execute 执行方法。

public interface Executor {
    void execute(Runnable command);
}
复制代码

ExecutorService 接口继承了接口 Executor 并新增了更多的任务执行必须的方法,例如:

  • void shutdown();
  • List shutdownNow();
  • boolean isShutdown();
  • Future submit(Callable task);
  • Future submit(Runnable task, T result);
  • Future<?> submit(Runnable task);
  • invokeAll,invokeAny等
任务的监控

Future 接口用于监控我们的任务执行状态,是已提交但未执行,或是已取消,亦或是已完成。Future 接口中定义的方法我们也不妨列举部分感受一下:

  • boolean cancel(boolean mayInterruptIfRunning);
  • boolean isCancelled();
  • boolean isDone();
  • V get()

任务的监控 Future 将在任务的提交成功后返回,也就是当你成功的调用 submit 方法之后,ExecutorService 将为你返回一个 Future 接口实例供你监控刚刚提交的任务执行状态。

通过 Executors 的工厂方法获取一个单线程的任务执行者,接着我们可以向这个任务执行者提交任务,并从 submit 方法的返回得到了任务的监控者 Future 实例。接着,我们也就可以通过 Future 来得知任务执行的状态。总的来说,异步任务给我们带来的好处是什么呢?我觉得最重要的一点就是「便捷」。

我只需要将我的任务提交就好了,不再关心如何如何创建线程,启动线程等等细节,我也不再像以前一样,线程启动后根本不知道有没有执行,我手里有 Future,我可以随时的监控任务的执行情况。
复制代码

另外,异步任务框架还有一点非常的不错,那就是性能,它可以依赖线程池,减少线程创建和销毁的开销,这一切都将随着 jdk 的迭代而不断的优化,而我们在使用上根本不用关心,我只关心我的任务该怎么写,至于任务怎么执行,如何高效低能耗,交给你异步框架了。

基本的实现原理

ExecutorService 继承了 Executor 并新增了一些接口方法,这些方法数量还不少,而有些方法是很通用的,亦或是有些方法子类用不到,这你不能要求每一个子类实现者都实现了这些方法。 所以,向下又有了一层抽象,AbstractExecutorService 实现了 ExecutorService 并完成了很多方法的默认实现。后者只需要继承 AbstractExecutorService 并重写自己需要重写的方法即可成为一个「异步任务的执行者」。 但是如下的方法 AbstractExecutorService 是没有做默认实现的,需要你子类自行实现。原因也很简单,因为这些方法不具备通用的逻辑,涉及到具体实现者内部使用的资源释放,锁资源竞争以及队列资源的使用等,所以不太适合做抽象。

public void shutdown()
public List<Runnable> shutdownNow()
public boolean isShutdown()
public boolean isTerminated()
public boolean awaitTermination(long timeout, TimeUnit unit)
public void execute(Runnable command)
复制代码

submit 主要有三种重载:

Future<?> submit(Runnable task)
Future submit(Runnable task, T result)
Future submit(Callable task)
复制代码

因为任务的抽象表示主要有两种,一种是 Runnable,一种是 Callable,所以需要提供对两种不同任务类型的抽象提交。我们以其中一个重载进行分析即可,这里我们采用第一个重载方法:

public Future<?> submit(Runnable task) {
    if (task == null) throw new NullPointerException();
    RunnableFuture<Void> ftask = newTaskFor(task, null);
    execute(ftask);
    return ftask;
}
复制代码

RunnableFuture 继承了 Runnable 和 Future,标识这是一个可执行的、可监控的任务。而 newTaskFor 返回的是 FutureTask (RunnableFuture的一种实现类)。

而我们也不妨看看这个 FutureTask 内部都有些哪些成员:

state 和它可取的这些值共同描述了当前任务的执行状态,是刚开始执行,还是正在执行中,还是正常结束,还是异常结束,还是被取消了,都由这个 state 来体现。

callable 代表当前正在执行的工作内容,这里说一下为什么只有 Callable 类型的任务,因为所有的 Runnable 类型任务都会被事先转换成 Callable 类型,我觉得主要是统一和抽象实现吧。 outcome 是任务执行结束的返回值,runner 是正在执行当前任务的线程,waiters 是一个简单的单链表,维护的是所有在任务执行结束之前尝试调用 get 方法获取执行结果的线程集合。当任务执行结束自当唤醒队列中所有的线程。 除此之外,还有一个稍显重要的方法,就是 run 方法,这个方法会在任务开始时由 ExecutorService 调用,这是一个很核心的方法。

  1. 如果任务已经开始将退出方法逻辑的执行
  2. 调度任务执行,调用 call 方法
  3. 调用成功将保存结果,异常则将保存异常信息
  4. 处理中断

回到 submit 方法时,其实就只剩下一个 execute 方法了,execute 方法是有点复杂的,也稍繁琐,其中也涉及了一些线程池的概念。execute 会根据线程池中可用线程的数量,分配并选择一个线程执行我们的任务。

实现暴力结束线程 线程生产者消费者的综合实战结合Java8语法 如何实现一个自己的显式锁Lock

转载于:https://juejin.im/post/5caff1c3e51d456e7618a693

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值