线程状态与线程池

多线程

线程的7种状态

  • 新建(new):线程被创建
  • 就绪(runnable或ready):线程正在参与竞争cpu的使用权
  • 运行(running):线程获取到了cpu的使用权,正在执行
  • 阻塞(blocked):线程为等待某个对象的“锁”而暂时放弃cpu的使用权,且不再参与cpu使用权竞争。直到条件满足时,重新回到就绪状态,重新参与竞争cpu。
  • 等待(waiting):线程无限等待某个对象的“锁”,或等待另一个线程结束的状态到来。
  • 计时等待(time_waiting):在一段时间内等待某个对象的“锁”,或者主动休眠,抑或等待另外一个线程结束(join)。除非被中断,否则时间一到,(超时)线程将自动回到runnable状态,被中断的方法通常会抛出中断异常(InterruptedException)。超时方法会抛出超时异常(TimeoutException)。
  • 终止(terminated或dead):线程所运行的代码被执行完毕;执行过程中出现异常;或受到外界干预而中断执行。

这些线程的状态可在java.lang.Thread.State中找到。 同样,线程模型在JVM中也有定义,可通过jstack 命令获取到运行瞬时的线程状态。

进程与线程

在操作系统中,进程是资源分配的最小单位。每个进程都有独立的代码和数据空间,称为进程上下文。 进程之间的切换会有较大开销,因此引入了线程。线程共享代码和数据空间,每个线程有独立的运行栈和程序计数器(PC)。是cpu调度的最小单位。 线程和进程一样分为五个阶段:创建、就绪、运行、阻塞、终止。但线程中的状态只是虚拟机状态,它不反映任何操作系统的线程状态。

waiting和block的区别

block是等待获取monitor对象,waiting是等待另一个线程完成某个操作。因此wating可以被interrupt()中断或者notify()唤醒,获取到monitor时直接进入runnable状态,而同类的其它线程则进入block状态。

Monitor 监视器

monitor定义于jvm中,每个对象都有一个monitor。 monitor中有一个owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。 每个线程都有一个可用monitor record列表,和一个全局的可用列表。 synchronized关键字便是用monitorenter与monitorexit指令实现的。

Thread常用方法

以下t为Thread的实例

  • t.start();启动线程t,t将从new状态转化为runnable状态,开始参与cpu竞争。
  • t.checkAccess();检查当前线程是否有权限访问线程t
  • t.interrupt(); 尝试通知线程t中断,需要在线程的任务代码中使用。
  • t.isInterruped(); 检测线程是否被要求中断。当此方法返回true时,当前线程应判断是否要中断执行。如果此时不中断执行,再次调用此方法将返回false
  • t.setPriority(8); 设置线程t的优先级1~10,值越大,得到执行的机会越高
  • t.isDaemno(); 判断线程t是否为守护线程,当进程中仅剩守护线程时jvm将退出
  • t.setDaemno(true); 仅用于在start()前设置线程t是否为守护线程
  • t.isAlive(); 判断线程t是否存活
  • t.join(100L); 当前线程等待线程t终止。参数为超时时间
  • Thread.yield(); 当前线程让出cpu,并转为runnable状态,重新参与cpu的竞争。只有优先级大于或等于当前线程的线程才可能获得cpu使用权。
  • Thread.sleep(100L); 当前线程让出cpu,睡眠100ms后回到runnable状态,重新参与cpu竞争
  • Thread.currentThread(); 得到当前线程对象的引用。

其中的大多数方法都是native的,都可在JVM的c语言中找到对应的实现。

wait与sleep的区别

wait()方法是所有Object类的方法,是线程同步的重要手段之一。 两者都可以让程序阻塞指定毫秒数,都可以通过interrupt()方法打断。 但两者有很大的不同之处:

  • wait()方法必须在synchronized同步代码块或方法中使用。
  • wait()方法会释放持有的monitor锁,而sleep不会释放资源。
  • wait()方法形成的阻塞,可以通过同一对象的notify()来唤醒;sleep()无法被唤醒,只能等待时间到来或被interrupt()中断。

sleep与yield的区别

  • sleep()方法先转入block状态,后回到runnable状态。yield()直接进入runnable状态。
  • sleep()后,其它线程无论优先级高低,都有机会得以执行。yield()后只有比它优先级高或同等的线程才有机会执行。
  • sleep()方法需要声明抛出InterruptedException,而yield()没有声明任何异常。
  • sleep()比yield()具有更好的可移植性。

线程池

线程的创建和销毁都会消耗资源。在高并发的情况下,频繁的创建和销毁线程会严重降低系统的性能。这也是《阿里巴巴java开发规范》六-3中所强制要求的。

最小线程数 最大线程数 线程最大空闲时长 描述
SingleThreadExecutor 1 1 0 只有一个线程在工作,串行执行所有任务,按照提交的先后顺序。如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。
FixedThreadPool n n 0 线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。
CachedThreadPool 0 int_max 60s 如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲(60秒不执行任务)的线程,当任务数增加时,此线程池又可以智能的添加新线程来处理任务。此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说JVM)能够创建的最大线程大小
ScheduledThreadPool n int_max DEFAULT_KEEPALIVE_MILLIS 定时以及周期性执行任务

在线程池中使用BlockingQueue作为任务队列。

BlockingQueue

当获取队列元素但是队列为空时,会阻塞等待队列中有元素再返回;也支持添加元素时,如果队列已满,那么等到队列可以放入新元素时再放入。BlockingQueue有如下4个常用实现.

ArrayBlockingQueue

该类主要是通过ReentrantLock 来实现等待和通知的。 ReentrantLock有公平锁(FairSync)和非公平锁(NonfairSync,默认实现)两种实现,他们都是继承自AbstractQueuedSynchronizer(AQS)的代码实现。 在AQS中,首先试着抢锁,抢到了则执行下去;没抢到则CAS的方式放入阻塞队列,挂起当前线程(LockSupport.park(this);),等待被唤醒,或者中断。 阻塞队列 的结构是一个双向链表。头节点和当前正在运行的节点不在其中。取消排队的节点不会第一时间主动移除。当前节点的waitStatus代表的是后一节点需要做的操作: CANCELLED(1)取消了争抢这个锁。 SIGNAL(-1)需要被唤醒。 ArrayBlockingQueue 通过它内部的ReentrantLock创建了两个Condition(条件队列),notEmpty和notFull.在进入到条件队列时,都得获取到ReentrantLock的锁,才能继续操作。 在take()时,如果任务数=0,当前调度线程将放入notEmpty条件队列,完全释放锁,并进入阻塞状态,等待(put())唤醒或中断。 在put()时,如果任务数=max_size,任务线程户籍放入notFull条件队列,完全释放锁,并进入阻塞状态,等待(take())唤醒或中断。 唤醒条件队列中的线程后,会被放入阻塞队列重新参与锁的竞争。

线程中断

中断代表着一个线程的状态、标识,是一个 true 或 false 的 boolean 值。与上文所说的线程七态并不是同一概念。与操作系统中的线程中断也不是一个概念,只是修改了中断状态,而非切换了上下文。中断之后的操作(抛出异常、忽略或者别的什么),需自己制定。 常用的三种中断方法:

  • public boolean isInterrupted() 检测当前线程是否中断。
  • public static boolean interrupted() 返回中断状态的同时,会将标识重置为 false。
  • public void interrupt() 设置一个线程的中断状态为true. 中断需要我们自己编码去监控,如下代码所示:
while (!Thread.interrupted()) {
   doWork();
   System.out.println("我做完一件事了,准备做下一件,如果没有其他线程中断我的话");
}
复制代码

除了代码轮询监控外,处于如下4种情况的线程,能自动感知到被中断了:

  1. Object的wait()方法、Thread的join(),sleep()方法;中断后会抛出中断异常(InterruptedException)
  2. 实现了 InterruptibleChannel 接口的类中的一些 I/O 阻塞操作。中断后会抛出异常(ClosedByInterruptException)并重置中断状态。
  3. Selector 中的 select 方法。一旦中断,方法立即返回。
  4. 线程阻塞在 LockSupport.park(Object obj) 方法。唤醒后不会重置中断状态。
中断异常(InterruptedException)

通常的,带有 throws InterruptedException的方法称为阻塞方法。如果希望它能早点返回的话,往往可以通过中断来实现。

处理中断

一般会有显示声明中断异常和没有中断异常两种方法。 没有声明中断异常的,在中断发生后不做任何处理,仅记录中断状态。 而声明中断异常的方法中,往往第一行代码便是检测是否中断。

if (Thread.interrupted())
        throw new InterruptedException();
复制代码
LockSupport.park 线程挂起

park的实现是调用unsafe方法中的park(),unsafe中的方法大都是native的c++代码实现。 在虚拟机HotSpot中,每个java线程都有一个Parker的实例。用conditionmutex维护了一个_counter变量。park时,变量_counter置为0,unpark时,变量_counter置为1。

LinkedBlockingQueue

内部维护了一个链表队列,分别持有它的头、尾指针,使用AtomicInteger计数。 读操作时,需要获取读锁ReentrantLock takeLock,如果读取队列为空,此时需要等待Condition notEmpty 条件。如果读取之后的count>1,则通知挂起的读线程notEmpty.signal();。最后,如果count == capacity(初始容量),而此时又消耗掉了一个元素,说明队列不满了,则唤醒写线程进行写入notFull.signal();。写操作同理。 它与BlockingQueue不同之处有三点:

  1. LinkedBlockingQueue内部维护了一个链表,而BlockingQueue使用的是ReentrantLock自带的数组结构。
  2. LinkedBlockingQueue中的读写队列分别持有不同的ReentrantLockBlockingQueue则使用一个ReentrantLock的两个条件队列。
  3. BlockingQueue中的take()和pull(),是两个条件队列相互唤醒。而LinkedBlockingQueue中除了最后的相互唤醒之外,还可以‘自我’唤醒。

SynchronousQueue

SynchronousQueue的最大特征是同步,当一个写线程向里面写入元素的时候,它不会立即返回,而是等待一个读线程来把元素直接取走。一个读线程匹配一个写线程。 它不提供任何空间(一个都没有)来存储元素。数据必须从某个写线程交给某个读线程,而不是写到某个队列中等待被消费。 其中最重要的交换代码是TransferQueue的transfer()来实现的。

PriorityBlockingQueue

PriorityBlockingQueue的两大特点是无界优先级PriorityBlockingQueue 使用了基于数组的二叉堆来存放元素,所有的 public 方法采用同一个 lock 进行并发控制。 无界表现在堆可以用数组存储,因此可以很方便的动态扩容。 优先表现在堆的特性:从根节点到任意结点路径上所有的结点都是有序的。

理解堆的三个关键点:

  1. 完全二叉树
  2. 任一结点的值是其左右子树的最大值
  3. 用数组实现

堆定义

class MaxHeap<T extends Comparable<T>> {
     private List<T> list;
     public MaxHeap(){
       list = new ArrayList<>();
     }
  }
复制代码

堆的元素插入实现

public void put(T node){
        list.add(node);
        int nowIndex = list.size() -1;
        
        int pIndex = nowIndex / 2;
        while(list.get(pIndex).compareTo(node) <= 0 && nowIndex >= 1){
          list.set(nowIndex,list.get(pIndex));
          nowIndex = pIndex;
          pIndex = nowIndex / 2;
        }
        list.set(nowIndex, node);
        printList();
    }
复制代码

插入的三大要点:

  1. 新插入的结点添加到数组最后
  2. 和其父结点比较大小,如果大于父结点,就用父结点替换当前位置,同时自己的位置上移。
  3. 直到父结点不再大于自己或者是位置已近到了数组第一个位置,就找到属于自己的位置了。

堆的删除实现

public void take(T node){
        if(list.size() <= 0){
          return;
        }
       int nIndex = list.indexOf(node);
       list.set(nIndex, list.get(list.size()-1));
        T nNode = list.get(nIndex);
        int childIndex = nIndex *2;
        while(childIndex <= list.size() -1){
            //  先左右子节点比较,找到最大的,再和node比较。
            if(childIndex != list.size() -1 && list.get(childIndex).compareTo(list.get(childIndex+1)) <0){
              childIndex++;
            }
            // 当前节点比左右子节点都大,符合最大堆的定义,位置找到,退出循环。
            if(nNode.compareTo(list.get(childIndex)) >=0){
              break;
            }else{
              // 子节点替换父节点位置
              list.set(nIndex, list.get(childIndex));
              nIndex = childIndex;
              childIndex = childIndex *2;
            }
        }
        // 插入位置
        list.set(nIndex, nNode);
        // 移除最后的元素,因为最后的元素被移动到删除位置,然后经过比较,移动到了nIndex。
        list.remove(list.size()-1);
        printList();
    }
复制代码

删除的四大要点:

  1. 找到要删除的结点在数组中的位置
  2. 用数组中最后一个元素替代这个位置的元素
  3. 当前位置和其左右子树比较,保证符合最大堆的结点间规则
  4. 删除最后一个元素

参考资料

  1. 一行行代码分析AQS
  2. java并发队列BlockingQueue分析
  3. 从使用到原理学习Java线程池
  4. 数据结构-堆

猜你喜欢

转载自juejin.im/post/5cde6e59e51d4510835e01e1