今天我们来聊聊多线程的一些知识,帮助同学们理解多线程的底层原理,更好的去利用多线程技术提升程序性能。一般工作3-5年以后,大家都会接触到多线程技术,这里就不累述多线程的优缺点和基本使用方法了。
首先我们来看下,线程的同步。大家自然会想到最经典的生产者和消费者模型:当队列满时,生产者需要等待队列有空间才能继续往里面放入商品,而在等待的期间内,生产者必须释放对临界资源(即队列)的占用权。因为生产者如果不释放对临界资源的占用权,那么消费者就无法消费队列中的商品,就不会让队列有空间,那么生产者就会一直无限等待下去。因此,一般情况下,当队列满时,会让生产者交出对临界资源的占用权,并进入挂起状态。然后等待消费者消费了商品,然后消费者通知生产者队列有空间了。同样地,当队列空时,消费者也必须等待,等待生产者通知它队列中有商品了。这种互相通信的过程就是线程间的协作。这里写一个生产者和消费者的例子(这也是教课书上通常教我们使用的方式)
1.最基本的使用wait(), notify()方式来实现
public class PCExample0 { private int queueSize = 10; /** * 用一个队列来模拟我们的仓库 */ private PriorityQueue<Integer> queue = new PriorityQueue<>(queueSize); //消费者 class Consumer extends Thread { @Override public void run() { consume(); } private void consume() { while (true) { synchronized (queue) { while (queue.size() == 0) { try { System.out.println("仓库空了,没有商品"); queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); queue.notify(); } } //每次移走队首元素 queue.poll(); queue.notifyAll(); System.out.println("取走一个商品,仓库里剩余" + queue.size() + "个商品"); } } } } //生产者 class Producer extends Thread { @Override public void run() { produce(); } private void produce() { while (true) { synchronized (queue) { while (queue.size() == queueSize) { try { System.out.println("仓库满了,没有空余空间"); queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); queue.notify(); } } //每次插入一个元素 queue.offer(1); queue.notify(); System.out.println("放入一个商品,仓库剩余空间:" + (queueSize - queue.size())); } } } } public static void main(String[] args) { PCExample0 test = new PCExample0(); Producer producer = test.new Producer(); Consumer consumer = test.new Consumer(); producer.start(); consumer.start(); }} 第一种方式也最简单的线程同步,这里要特别说明一下,为什么要使用synchronized关键字,这是确保线程拿到queue这个对象实例的监视器锁,线程获取了锁,则等于获得了该对象的操作权限,这时候才可以进行wait和notify的操作。但这种方式只是实现了线程间的互斥,再很多应用场景中,我们希望线程之间可以互相影响(专业点说法叫:线程间的通信)所以java在1.5以后引入了Condition类,它用来替代传统的Object的wait()、notify()实现线程间的协作,相比使用Object的wait()、notify(),Condition 将 Object 监视器方法(wait、notify 和 notifyAll)分解成截然不同的对象,以便通过将这些对象与任意 Lock 实现组合使用,为每个对象提供多个等待 set(wait-set)。其中,Lock 替代了 synchronized 方法和语句的使用,Condition 替代了 Object 监视器方法的使用。一个锁可以有多个条件,每个条件上可以有多个线程等待,通过调用await()方法,可以让线程在该条件下等待。当调用signalAll()方法,又可以唤醒该条件下的等待的线程。
2.基于Condition的生产者消费者模型
private int queueSize = 10;/** * 用一个队列来模拟我们的仓库 */private PriorityQueue<Integer> queue = new PriorityQueue<>(queueSize); /** * 重入锁,稍后会详细介绍 */private Lock lock = new ReentrantLock();private Condition notFull = lock.newCondition();private Condition notEmpty = lock.newCondition(); class Consumer extends Thread{ @Override public void run() { consume(); } private void consume() { while(true){ lock.lock(); try { while(queue.size() == 0){ try { System.out.println("仓库空了,没有商品"); notEmpty.await(); } catch (InterruptedException e) { e.printStackTrace(); } } queue.poll(); notFull.signal(); System.out.println("取走一个商品,仓库里剩余" + queue.size() + "个商品"); } finally{ lock.unlock(); } } }} class Producer extends Thread{ @Override public void run() { produce(); } private void produce() { while(true){ lock.lock(); try { while(queue.size() == queueSize){ try { System.out.println("仓库满了,没有空余空间"); notFull.await(); } catch (InterruptedException e) { e.printStackTrace(); } } queue.offer(1); notEmpty.signal(); System.out.println("放入一个商品,仓库剩余空间:" + (queueSize - queue.size())); } finally{ lock.unlock(); } } }} public static void main(String[] args) { ExecutorService pool = Executors.newFixedThreadPool(2); PCExample1 test = new PCExample1(); Producer producer = test.new Producer(); Consumer consumer = test.new Consumer(); pool.submit(producer); pool.submit(consumer);}上面这段代码用到了ReentrantLock,中文名字叫重入锁,在Java中实现锁有两种方式,一种是synchronized关键字,另一种是Lock。synchronized是基于JVM层面实现的,而Lock是基于JDK层面实现的。ReentrantLock实现Lock接口,“重入”这个概念的含义是:线程可以对共享资源能够被重复加锁,即当前线程获取该锁再次获取不会被阻塞。ReentrantLock有两种方式,公平锁和非公平锁(默认是非公平的,这里先不解释这两种方式的区别,后面会有详细分析)。“临界区”的代码放在lock()和unlock()中间,起到了和synchronized类似的作用。
在java体系里,有一个很特殊的类Unsafe。Unsafe类是在sun.misc包下,不属于Java标准。但是很多Java的基础类库,包括一些被广泛使用的高性能开发库都是基于Unsafe类开发的,比如Netty、Cassandra、Hadoop、Kafka等。Unsafe类在提升Java运行效率,增强Java语言底层操作能力方面起了很大的作用。Unsafe类使Java拥有了像C语言的指针一样操作内存空间的能力,java中最基本的多线程操作都是通过调用Unsafe包的函数来实现的,Unsafe包的方法都是native的,C++实现的,底层调用了glibc nptl包中符合posix标准的线程同步工具。
Unsafe中线程挂起和恢复的方法
/** * 线程挂起 * 如果isAbsolute是true则会实现ms定时。如果isAbsolute是false则会实现ns定时。 * @param isAbsolute 是否是绝对时间 * @param time 等待时间值 */public native void park(boolean isAbsolute, long time); /** * 线程恢复 * @param jthread */public native void unpark(Thread jthread);在此基础上java包装了LockSupport类,提供了基本的线程同步原语。LockSupport中同样提供了park() 和 unpark()方法,底层调用了上面介绍的Unsafe包中的park()和unpack(),从而实现挂起和恢复线程的操作。我们先来看一个简单的例子,方便我们了解如何使用 LockSupport类。
public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { int sum = 0; for (int i = 0; i < 10; i++) { sum++; } System.out.println("parking..."); LockSupport.park(); System.out.println("sum: " + sum); }); t1.start(); for (int j = 0; j < 3; j++) { Thread.sleep(1000); System.out.println("Elapsed 1 seconds..."); } LockSupport.unpark(t1);} t1线程在运行时调用了LockSupport的park(),将自己挂起,然后再主线程中通过unpark方法唤醒t1线程,继续执行,打印出计算结果。我使用了阻塞和唤醒,是为了和wait/notify做对比。其实park/unpark的设计原理核心是“许可”。park是等待一个许可。unpark是为某线程提供一个许可。如果某线程A调用park,那么除非另外一个线程调用unpark(A)给A一个许可,否则线程A将阻塞在park操作上。有一点比较难理解的,是unpark操作可以再park操作之前。也就是说,先提供许可。当某线程调用park时,已经有许可了,它就消费这个许可,然后可以继续运行(注意:在工作中应该使用Lock和synchronized来处理多线程程序,LockSupport和Unsafe过于底层,在没有非常理解其原理贸然使用的话,很可能会引发意想不到的后果)。
3.同步器
有了已上的基础,我们就可以开始着手讨论ReentrantLock的实现了,先来看下java官方的类图:
ReentrantLock实现了Lock接口,而内部包含的一个叫“Sync”的类,即同步器,两个具体的子类非公平同步器和公平同步器构成了锁或者其他相关同步装置的基础框架。而这个同步器又具体是一个什么东西呢?用白话来讲就是个FIFO队列(先进先出),当一个线程获得锁以后,其他线程需要排队,当第一线程释放了锁,然后轮到其他线程获取,如果是非公平的,那线程就会去抢占,如果是公平的,就完全按照顺序。AbstractQueuedSynchronizer是同步器的核心,维护着一个CLH(Craig, Landin and Hagersten) 锁,CLH锁也是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,它不断轮询前驱的状态,如果发现前驱释放了锁就结束自旋,由FIFO的队列实现的。文字总是苍白无力,我们这里手动来实现一个CLH
public class SimpleCLHLock { class Node { /** * false代表没人占用锁 */ volatile boolean locked = false; } /** * 指向最后加入的线程,形成一个虚拟的链表 */ final AtomicReference<Node> tail = new AtomicReference<>(new Node()); /** * 使用ThreadLocal保证每个线程副本内都有一个Node对象 */ final ThreadLocal<Node> current; public SimpleCLHLock() { //初始化当前节点的node current = ThreadLocal.withInitial(() -> new Node()); } public void lock() throws InterruptedException { //得到当前线程的Node节点 Node own = current.get(); //修改为true,代表当前线程需要获取锁 own.locked = true; //设置当前线程去注册锁,注意在多线程下环境下,这个 //方法仍然能保持原子性,,并返回上一次的加锁节点(前驱节点) Node preNode = tail.getAndSet(own); //在前驱节点上自旋 while (preNode.locked) { System.out.println(Thread.currentThread().getName() + " 自旋中.... "); Thread.sleep(2000); } } public void unlock() { //当前线程如果释放锁,只要将占用状态改为false即可 //因为其他的线程会轮询自己,所以volatile布尔变量改变之后 //会保证下一个线程能立即看到变化,从而得到锁 current.get().locked = false; current.remove(); } public static void main(String[] args) { SimpleCLHLock lock = new SimpleCLHLock(); Runnable runnable = () -> { try { lock.lock(); System.out.println(Thread.currentThread().getName() + " 获得锁 "); //前驱释放,do own work Thread.sleep(5000); System.out.println(Thread.currentThread().getName()+" 释放锁 "); lock.unlock(); } catch (InterruptedException e) { e.printStackTrace(); } }; ExecutorService pool = Executors.newFixedThreadPool(3); pool.submit(new Thread(runnable,"thread-1")); pool.submit(new Thread(runnable,"thread-2")); pool.submit(new Thread(runnable,"thread-3")); }}好了,我们现在已经了解CLH锁的基本用途,AbstractQueuedSynchronizer是通过一个内部类Node来实现CLH lock queue的一个变种,但基本原理是类似的。下面就是AbstractQueuedSynchronizer维护的一个队列
第一个进入“临界区”的线程获取到锁以后,如上图所示,获取不到锁的线程,会进入队尾,然后自旋,直到其前驱线程释放锁。这样做的好处:假设有1000个线程等待获取锁,锁释放后,只会通知队列中的第一个线程去竞争锁,减少了并发冲突。(ZK的分布式锁,为了避免惊群效应,也使用了类似的方式:获取不到锁的线程只监听前一个节点)为什么说AbstractQueuedSynchronizer中的实现是基于CLH的“变种”,因为原始CLH队列,一般用于实现自旋锁。而AbstractQueuedSynchronizer中的实现,获取不到锁的线程,一般会时而阻塞,时而唤醒。Node的主要包含以下成员变量:
thread:入队列时的当前线程。
prev:前驱节点,比如当前节点被取消,那就需要前驱节点和后继节点来完成连接。
next:后继节点。
waitStatus:表示节点的状态。其中包含的状态有:
CANCELLED,值为1,表示当前的线程被取消;
SIGNAL,值为-1,表示当前节点的后继节点包含的线程需要运行,也就是unpark;
CONDITION,值为-2,表示当前节点在等待condition,也就是在condition队列中;
PROPAGATE,值为-3,表示当前场景下后续的acquireShared能够得以执行;
值为0,表示当前节点在sync队列中,等待着获取锁。
nextWaiter:存储condition队列中的后继节点。
AbstractQueuedSynchronizer的实现是非常复杂的,这里不打算全面详细的讲解整个AQS类,这里我们对线程加排他锁进行详细的描述,其他方法,比如共享锁,条件Condition实现都大同小异。AbstractQueuedSynchronizer包括了整个链表的属性,head(头节点),tail(尾节点),state(同步的状态)。head和tail比较好理解,state的作用是干嘛的呢?从实现的源码来看,state用于确定同步器是否在锁定状态,白话的讲在进入临界区的时候表示临界区的资源是否已经有线程占用了,state是一个int值,ReentrantLock中使用了AbstractQueuedSynchronizer的独占获取和释放,用state变量记录某个线程获取独占锁的次数,获取锁时+1,释放锁时-1,在获取时会校验线程是否可以获取锁。Semaphore中使用了AbstractQueuedSynchronizer的共享获取和释放,用state变量作为计数器,只有在大于0时允许线程进入。获取锁时-1,释放锁时+1。CountDownLatch中使用了AbstractQueuedSynchronizer的共享获取和释放,用state变量作为计数器,在初始化时指定。只要state还大于0,获取共享锁会因为失败而阻塞,直到计数器的值为0时,共享锁才允许获取,所有等待线程会被逐一唤醒。ReentrantLock的lock方法调用了sync的lock方法,默认非公平锁情况下,其实就是调用了NonfairSync的lock方法:
NonfairSync.lock()
final void lock() { //尝试能否将同步器的state值设置成1,对外可以理解为成功进入临界区 if (compareAndSetState(0, 1)) //将同步器的占用线程设置成自己 setExclusiveOwnerThread(Thread.currentThread()); else //如果设置失败,则调用AbstractQueuedSynchronizer的方法,尝试获取锁 acquire(1);}我们再来看看AbstractQueuedSynchronizer的acquire方法里的实现:
AbstractQueuedSynchronizer.acquire
public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt();}tryAcquire是一个protected方法,AbstractQueuedSynchronizer提供给继承的类自己实现,那这里其实是由NonfairSync来实现的,我们继续进入NonfairSync的tryAcquire的方法,里面调用了Sync(即NonfairSync的父类)的nonfairTryAcquire方法:
Sync.nonfairTryAcquire
final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); //获取当前同步器的状态,白话意思是看看临界区是不是已经被占用了 int c = getState(); //如果没有被占用,尝试去占用,将同步器的state设置成1 if (c == 0) { if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } // 如果已经被占用了,那看看占用的线程是不是就是自己,如果是,就将state继续加1,这里就是实现"重入"的关键代码 // 一个线程已经占有临界区后,可以再次占用临界区,只是将state这个计数器加1,在释放的时候递归释放,直到state为 // 0时,表示资源被彻底释放 else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) // overflow throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } // 如果state不为0(资源被占用),又不是自己占用的,则返回占用失败 return false;}现在我们反过来再来看下AbstractQueuedSynchronizer的acquire方法,这个方法的主要逻辑包括:
- 尝试获取(调用tryAcquire更改状态,需要保证原子性);在tryAcquire方法中使用了同步器提供的对state操作的方法,利用compareAndSet保证只有一个线程能够对状态进行成功修改,而没有成功修改的线程将进入sync队列排队。
- 如果获取不到,将当前线程构造成节点Node并加入等待队列(这里加入的是Node.EXCLUSIVE类型的节点,表示排他属性);进入队列的每个线程都是一个节点Node,从而形成了一个双向队列,类似CLH队列,这样做的目的是线程间的通信会被限制在较小规模(也就是两个节点左右)。
- 再次尝试获取,如果没有获取到那么将当前线程从线程调度器上摘下,进入等待状态。
入队列的关键代码如下:
addWaiter & enq
private Node addWaiter(Node mode) { // 构造节点 Node node = new Node(Thread.currentThread(), mode); // 获取队列尾巴tail Node pred = tail; // 在队列尾tail存在的情况下,将新节点的prev指向tail,然后将新节点置成新的tail // 而老的tail的next指向新节点node,这样就完成了一个链表 if (pred != null) { node.prev = pred; if (compareAndSetTail(pred, node)) { pred.next = node; return node; } } // tail如果是null,则通过enq方法初始化tail,然后再构建链表 enq(node); return node;} private Node enq(final Node node) { for (;;) { Node t = tail; if (t == null) { // 尾节点tail是null,先构建tail和head if (compareAndSetHead(new Node())) tail = head; } else { node.prev = t; if (compareAndSetTail(t, node)) { t.next = node; return t; } } }}入完队列以后,返回新创建的节点,作为参数传入acquireQueued方法,这个方法主要遍历队列,获取锁,获取不到锁,则放弃获取,我们来看下具体是怎么做的
AbstractQueuedSynchronizer.acquireQueued
final boolean acquireQueued(final Node node, int arg) { boolean failed = true; try { boolean interrupted = false; // 循环遍历队列,找到队列在head后面的第一个节点 for (;;) { final Node p = node.predecessor(); // 如果node的节点的前面是head,则表示本node在队列的第一位置,则可以去获取锁 if (p == head && tryAcquire(arg)) { // 获取锁成功后,新的node节点变成head setHead(node); // p即原来的head已经没有用了,废弃它的next引用,帮助虚拟机回收内存 p.next = null; // help GC failed = false; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); }} private final boolean parkAndCheckInterrupt() { //这里我们终于看到真正的暂停线程的方法,底层调用了LockSupport.park LockSupport.park(this); // 这里需要返回线程的中断状态,原因是LockSupport.park也可以响应中断请求 // 但是并不会抛出InterruptedException异常,被park的线程在收到其他线程的 // 中断请求后,会被唤醒(上面LockSupport的列子最后main里面唤醒线程,也 // 可以用t1.interrupt()) return Thread.interrupted();}这里针对acquire做一下总结:1. 状态的维护;需要在锁定时,需要维护一个状态(int类型),而对状态的操作是原子和非阻塞的,通过同步器提供的对状态访问的方法对状态进行操纵,并且利用compareAndSet来确保原子性的修改。2. 状态的获取;一旦成功的修改了状态,当前线程或者说节点,就被设置为头节点。3. sync队列的维护。在获取资源未果的过程中条件不符合的情况下(不该自己,前驱节点不是头节点或者没有获取到资源)进入睡眠状态,停止线程调度器对当前节点线程的调度。这时引入的一个释放的问题,也就是说使睡眠中的Node或者说线程获得通知的关键,就是前驱节点的通知,而这一个过程就是释放,释放会通知它的后继节点从睡眠中返回准备运行。
接下来,我们来看下同步器释放的操作是如何进行的,ReentrantLock的unlock方法用的就是AbstractQueuedSynchronizer的release方法
AQS.release
public final boolean release(int arg) { // 如果同步器状态变更为0,表示资源被释放 if (tryRelease(arg)) { Node h = head; if (h != null && h.waitStatus != 0) // 将头结点后续的节点的线程唤醒 unparkSuccessor(h); return true; } return false;} private void unparkSuccessor(Node node) { int ws = node.waitStatus; if (ws < 0) // 首先将节点状态改成标识后续节点需要唤醒 compareAndSetWaitStatus(node, ws, 0); // 正常情况下,应该取后续节点释放,但如果后续节点是null,或者是已经取消的节点 // 则需要从尾tail节点开始遍历,取最近的一个非取消节点 Node s = node.next; if (s == null || s.waitStatus > 0) { s = null; for (Node t = tail; t != null && t != node; t = t.prev) if (t.waitStatus <= 0) s = t; } if (s != null) // 唤醒该节点 LockSupport.unpark(s.thread);}回顾整个资源的获取和释放过程:在获取时,维护了一个sync队列,每个节点都是一个线程在进行自旋,而依据就是自己是否是首节点的后继并且能够获取资源;在释放时,仅仅需要将资源还回去,然后通知一下后继节点并将其唤醒。
4.同步器的应用
通过对java重入锁的解析,我们对java对临界区的控制,加锁,线程的等待和唤醒有了一个基本的认识,除了实现基本lock(),unlock()以外,还有很多方法我们就不一一介绍了,同步器除了实现lock以外,还实现了信号量(Semaphore),闭锁(CountDownLatch)等等多线程组件。为了加深对同步器的了解,我们写一个例子,有同步器实现一个自定义的排他锁,看看是不是好使。
public class Mutex implements Lock, java.io.Serializable { // 内部类,自定义同步器 private static class Sync extends AbstractQueuedSynchronizer { /** * 是否处于占用状态 * * @return */ @Override protected boolean isHeldExclusively() { return getState() == 1; } /** * 当状态为0的时候获取锁 * * @param acquires * @return */ @Override protected boolean tryAcquire(int acquires) { //排他锁只能设置成1,设置多个值的时候可以用来实现共享锁 assert acquires == 1; if (compareAndSetState(0, 1)) { setExclusiveOwnerThread(Thread.currentThread()); return true; } return false; } /** * 释放锁,将状态设置为0 * * @param releases * @return */ @Override protected boolean tryRelease(int releases) { //获取锁的时候只能设置成1,所以释放的时候也只能设置成1 assert releases == 1; // state为1表示处于被占用的状态,才能释放,如果是0,说明程序发生了异常 if (getState() == 0) { throw new IllegalMonitorStateException(); } setExclusiveOwnerThread(null); setState(0); return true; } Condition newCondition() { return new ConditionObject(); } } class IntegerHolder { private int count = 0; public int getCount() { return count; } public void setCount(int count) { this.count = count; } } /** * 仅需要将操作代理到Sync上即可 */ private final Sync sync = new Sync(); @Override public void lock() { sync.acquire(1); } @Override public void lockInterruptibly() throws InterruptedException { sync.acquireInterruptibly(1); } @Override public boolean tryLock() { return sync.tryAcquire(1); } @Override public boolean tryLock(long time, TimeUnit unit) throws InterruptedException { return sync.tryAcquireNanos(1, unit.toNanos(time)); } @Override public void unlock() { sync.release(1); } @Override public Condition newCondition() { return sync.newCondition(); } public static void main(String[] args) { Mutex mutex = new Mutex(); final IntegerHolder holder = mutex.new IntegerHolder(); ExecutorService pool = Executors.newFixedThreadPool(2); pool.submit(() -> { try { mutex.lock(); System.out.println(MessageFormat.format("{0} 进入临界区", Thread.currentThread().getName())); for (int i = 0; i < 10; i++) { holder.setCount(holder.getCount() + 1); System.out.println(MessageFormat.format("{0} 处理中, count:{1}",Thread.currentThread().getName(),holder.getCount())); Thread.sleep(100); } } catch (InterruptedException e) { e.printStackTrace(); } finally { System.out.println(MessageFormat.format("{0} 离开临界区", Thread.currentThread().getName())); mutex.unlock(); } }); pool.submit(() -> { try { mutex.lock(); System.out.println(MessageFormat.format("{0} 进入临界区", Thread.currentThread().getName())); for (int i = 0; i < 10; i++) { holder.setCount(holder.getCount() + 1); System.out.println(MessageFormat.format("{0} 处理中, count:{1}",Thread.currentThread().getName(),holder.getCount())); Thread.sleep(100); } } catch (InterruptedException e) { e.printStackTrace(); } finally { System.out.println(MessageFormat.format("{0} 离开临界区", Thread.currentThread().getName())); mutex.unlock(); } }); pool.shutdown(); }}