线程池

线程池工作原理

线程池创建完之后默认池中是没有线程的,每当任务提交之后,会创建一个线程去处理。如果池中线程数达到核心线程数,那么再提交的任务会放入阻塞队列中,如果阻塞队列满了,那会再创建线程去处理,直到池中线程数达到最大线程数,这时候再提交任务,就会走拒绝策略。

阻塞队列

先说下什么是阻塞队列。阻塞队列顾名思义是一个队列,并且在入队和出队的时候,是需要获取锁的,是阻塞的。
常用的阻塞队列实现有:ArrayBlockingQueue、LinkedBlockingQueue、ProrityBlockingQueue、SynchronousQueue。依次来说下这几个阻塞队列的特点。

  1. ArrayBlockingQueue。基于数组实现的阻塞队列,因为是数组所以也是有界的。
  2. LinkedBlockingQueue。基于链表实现的阻塞队列,因为链表特点所以队列是无界的。
  3. ProrityBlockingQueue。有优先级的阻塞队列,是有界的,由堆实现。
  4. SynchronousQueue。内部只能存储一个元素,所以入队的时候,必须阻塞等待,另一个线程把元素出队。

我们再看下入队和出队时的API的区别。

入队API 特点
add() 如果队列满了抛异常
offer() 队列满了返回false
put() 队列满了阻塞,直到入队成功,基于Condition等待队列实现,用await/signal机制实现
offer(time) 队列满了会等待一定时间,如果最后还是满了,那返回false
出队API 特点
remove() 队列为空抛异常
poll() 队列为空返回null
poll(time) 队列为空,等待一定时间,如果最后还是为空,返回null
take() 队列为空阻塞等待,直到队列不为空,返回获取到到元素

核心线程怎么保持存活的

线程池默认超过核心线程数的线程,会在超时时间之后销毁,剩下的线程会一直存活。这个实现的原理就是利用了阻塞队列出队的特点。
当池中线程数小于核心线程数时,线程从阻塞队列中取任务会调用take()方法,这个方法当队列为空时,会一直阻塞在那,直到队列不为空可以取到数据。
当池中线程数大于核心线程数时,线程从阻塞队列中取任务会调用poll(time)方法,这个方法当队列为空时,会等待一定时间,如果队列中还是为空,那poll(time)方法返回null,这样线程就没有任务处理了,自然就销毁了。

拒绝策略

  1. 直接抛异常,AbortPolicy
  2. 不处理也不抛异常,直接丢弃任务,DiscardPolicy
  3. 丢弃队列中最老的一个任务,DiscardOldestPolicy
  4. 让调用者的线程直接处理了任务,CallerRunsPolicy

    线程数量如何选择

    线程池中线程数量的选择需要根据业务场景来确定,主要分为两种:

  5. CPU密集型的任务。这种任务需要尽可能压榨CPU的处理能力,所以线程数选择和CPU核数相等就可以。

  6. IO密集型的任务。这种任务IO操作比较多,那其实CPU是有很多等待IO处理好的时间的,也就是CPU很多时间是空闲的,这时候线程数选择通常比CPU核数大。

    线程池状态

    创建了一个线程池之后就是runnable状态;当调用shutdown()方法是shutdown状态;当调用shutdownNow()方法是stop状态;在shutdown/stop状态,如果阻塞队列为空,池中没有存活的线程,进入tidying状态,而后会自己调用terminated()方法,进入terminated状态,这样线程池就关闭了。

    shutdown()和shutdownNow()的区别

    shutdown()调用之后,线程池进入shutdown状态,会尝试让没有在执行任务的线程中断。
    shutdownNow()调用之后,线程池进入stop状态,会尝试让所有线程中断。

    几种常用的线程池

    | 线程池 | 参数 | 使用场景 | | —- | —- | —- | | FixThreadPool | 核心线程数=最大线程数,超时时间为0,阻塞队列是LinkedBlockingQueue | 线程创建之后不会销毁,超过线程数的任务会放入阻塞队列中,这个队列是无界的,需要注意OOM。 | | CachedThreadPool | 核心线程数=0,最大线程数Integer最大值,超时时间一分钟,阻塞队列SynchronousQueue | 阻塞队列不存数据,所以任务一多就会创建线程去处理,而如果任务长时间没有线程也会都销毁,适合需要快速处理的任务 |
  • newSingleThreadExecutor():只有一个线程的线程池,任务是顺序执行,适用于一个一个任务执行的场景
  • newCachedThreadPool():线程池里有很多线程需要同时执行,60s内复用,适用执行很多短期异步的小程序或者负载较轻的服务
  • newFixedThreadPool():拥有固定线程数的线程池,如果没有任务执行,那么线程会一直等待,适用执行长期的任务。
  • newScheduledThreadPool():用来调度即将执行的任务的线程池

image.png
FixedThreadPool和SingleThreadExecutor:队列⻓度为 Integer.MAX_VALUE,会导致OOM。
CachedThreadPool和ScheduledThreadPool:线程数量为 Integer.MAX_VALUE,会导致OOM。

Lock

Lock和synchronized的区别

Lock锁是通过Java实现的,synchronized是JVM提供的一个关键字,而synchronized本身并不具备响应中断、超时等功能,Lock锁就提供了这些功能。此外还有一个特点,synchronized是不用主动释放锁的,而Lock是需要显示释放锁。

ReentrantLock

Lock是一个接口,只是说一个锁Lock需要具备哪些功能,具体实现是在其实现类中的,例如ReentrantLock。ReentrantLock中有公平锁和非公平锁的实现,这是两个内部类,是AQS的实现类,锁功能的具体实现就是在AQS中。

AQS实现原理

有一个volatile修饰的变量state作为锁标志,还有一个持有锁的线程变量,还有一个同步队列CLH队列。当我们需要加锁时,就是先看state是否为0,如果等于0,说明此时是无锁的状态,那么CAS修改state 0到1,成功就获取到锁;如果state不等于0,说明是有锁的状态,那持有锁到线程是不是自己,是的话state+1,也就是锁重入,如果都不是,那么就需要放到同步队列中等待。
image.png

CLH队列

CLH队列是一个双向队列,入队时节点放入队列尾部,并将前一个节点设置状态后,自己阻塞等待,让前一个节点出队的时候唤醒当前节点。节点出队会唤醒下一个节点,下一个节点唤醒之后要做的就是继续尝试获取锁资源。

非公平锁和公平锁的区别

非公平锁上来就先尝试CAS修改state从0到1,如果成功就获取到锁。

响应中断

当我们对正在运行对线程调用Thread.interrupt()方法时,只是给这个线程设置了一个中断标志位,该线程还会继续执行,但该线程中调用isInterrupted()方法时,会返回true,这样就可以进行一些线程中断后的处理。
如果调用了Thread.interrupt()方法,线程会设置中断标志位;再调用Thread.interrupted()方法可以清除中断标志位。

synchronized

对象头中有mark word和类型指针,synchronized锁的实现就依赖mark word中的信息。mark word中有锁状态、持有锁的线程ID、指向栈上的锁记录、GC年龄等信息。
当一个线程第一次用synchronized获取锁时,mark word中的锁状态会从无锁升级为偏向锁,并把持有锁的线程ID设置为这个线程,后面这个线程再获取锁时,只需要看持有锁的线程是不是这个线程。
当另外一个线程来竞争这个锁时,发现偏向锁的持有锁的线程不是自己,那么会把锁状态升级为轻量级锁,并让竞争锁的线程都尝试把栈上的锁记录设置到锁的mark word中,成功的线程就获取到了轻量级锁。
当轻量级锁自旋了一定次数,或者等待的线程达到一定数量,那么轻量级锁就需要升级为重量级锁。这时候重量级锁是需要由操作系统介入的,需要内核态和用户态的切换,所以性能更差。

volatile

volatile修饰的变量保证了有序性和可见性。
volatile实现可见性、有序性的原理:volatile修饰的变量在反汇编之后,可以发现多了一个Lock前缀的指令,这个指令可以保证:修饰的变量在工作内存中写完之后会立即同步回主内存;其他缓存了这个变量的工作内存,需要读这个变量时,从主内存中读最新值。此外Lock前缀的这个指令具有内存屏障的语义,可以确保Lock前的指令不会重排序到后面,后面的不会重排序到前面。

CAS

volatile不保证原子性,所以 k++ 这种并不是原子操作。那如果让k++操作具备原子性呢?在并发包下的原子类AtomicInteger可以实现,原子类的实现原理是通过CAS实现的。
CAS实现原理:调用的参数有:旧值、新值、期望值。当旧值=期望值时,可以将旧值设置为新值;当旧值!=期望值时,这次操作就是失败。
CAS本质就是一种乐观锁,乐观锁适合读多写少的场景,悲观锁适合频繁写入的场景
CAS还存在ABA问题,也就是期望值在之前被改过两次,正好又改回来了。可以通过加版本号解决ABA问题。Java并发包下AtomicStampedReference可以解决ABA问题。

线程状态

  1. new。还未Thread.start()的线程就是new状态。
  2. runnable。调用了Thread.start(),然后就可以运行,等待CPU分配时间片。
  3. blocked。等待获取监视器锁(synchronized)。
  4. waiting。Object.wait()/Thread.join()/Thread.sleep()/Unsafe.park()
  5. timed-waiting。
  6. terminated。

    Java内存模型

    Java内存模型是一套Java语言层面多线程访问共享内存的规范。

  7. 数据存储在主内存,线程访问数据时,需要从主内存拷贝一份到工作内存,在工作内存中修改之后,需要同步回主内存。

  8. 定义了主内存和工作内存的交互规则(包括一些指令、指令的规定等),有个happens-before原则来判断这个规则。happens-before原则能够保证可见性和有序性。

    计算机内存模型 内存抽象架构:数据存储在内存(这里不说外存磁盘),CPU读数据,回从内存读到L3、L2、L1不同的高速缓存,然后再到CPU,数据更新完了也要写会内存。 内存交互操作规范:而这种有缓存的架构,势必会有缓存不一致的问题,计算机是通过缓存一致性协议(最出名的就是Intel 的MESI协议)来保证缓存中的数据是一致的。

需要注意的是,Java内存模型在多线程操作共享数据时,是会有并发安全问题的,主要围绕原子性、可见性、有序性三点。Java内存模型(或者说Java语言)也是为我们提供了一些措施来保证并发安全:

原子性

原子性是指在原子操作之间,不会被别的线程打断。
Java内存模型保障了一些简单的赋值和读取操作是原子的,例如int a = 1,但别的操作就不保证原子性了,那这时候Java也为我们提供了synchronized关键字来保障原子性。

可见性

可见性是指变量被修改之后会立刻被别的线程感知。
Java中提供了volatile关键字来保障修饰的变量的可见性,此外synchronized和final关键字也能保障可见性。

  • volatile的可见性是由于其修饰的变量在线程每次更新了之后回立刻同步回主内存,并且线程每次操作其修饰的变量也都需要去主内存读入(即使工作内存中已经有了);
  • synchronized的可见性是由于在解锁unlock之前会把共享数据同步回主内存;
  • final的可见性应该就是由于其修饰的变量的不可变。

    有序性

    线程内有序,线程外无序。
    有序性主要是由于指令重排的存在,但Java内存模型能够保障即使发生了指令重排,其执行结果还是和程序书写顺序一致(happens-before),但在多线程的情况下,在别的线程看来是无序的。
    而指令重排并不是说什么指令都会被重排序,那么有关系的指令是不会被重排序的,例如int a = 1;int b=a;。但有时候那么没有依赖关系的指令,我们也不希望被重排序,经典的例子:双检锁单例。那这个时候,我们如何来保障有序性呢?Java内存模型为我们提供了3种方式:synchronized(串行化,一次只能一个线程执行同步代码)、volatile(内存屏障)、happens-before原则。

    happens-before原则

    happens-before原则是说,如果A先行发生与B,那么A的结果对于B来说就是可见的。这句话看着像是说happens-before保障了可见性,但其实说的是先后顺序关系,是保证了有序性。我们可以利用happens-before原则方便的判断程序是否有有序性保证,其具体内容主要有以下几点:
  1. 程序次序原则(在一个线程里,代码书写在前面的先行发生于后面的操作);
  2. synchronized原则(释放锁先行发生于后面同一个锁的获取锁);
  3. volatile原则(对一个变量对写操作先行发生于对同个变量的读操作);
  4. 关系传递原则。

    ThreadLocal

    ThreadLocal保存线程私有变量原理

    ThreadLocal可以用来保存线程私有的变量,其实现原理依靠Thread类中的ThreadLocalMap,例如我们创建了一个ThreadLocal实例,并设置了一个变量,那其实这个ThreadLocal实例和变量,是会存储在Thread.ThreadLocalMap中的,map的key就是ThreadLocal,value是变量,并且需要注意的是,这个Thrad就是当前线程的Thread实例。

    内存泄露问题

    此外,ThreadLocalMap并不是简单的map,map的key是弱引用。也就是说,当发生GC时,key(ThreadLocal)就会被回收,这样Map中就出现key为null,value有值的Entry;并且只要这个线程存在,那么 线程Thread->ThreadLocalMap->value这条引用链一直存在,value就发生了内存泄露。
    为了解决这个问题,在调用ThreadLocal.get()/set()方法的时候,ThreadLocalMap都会主动移除key为NULL的元素。

    分配的内存无法释放。

那为什么这里要用弱引用呢?
如果是强引用,假如我们在方法中创建了一个ThreadLocal实例,并为其设置了一个变量,那么这个时候这个ThreadLocal其实是被两个地方引用的:栈帧的本地变量表和Thread.ThreadLocalMap。如果方法运行退出,那么最后剩下和ThreadLocalMap的引用链,而如果这个引用是强引用,那这个ThreadLocal就一直不会被回收,直到线程运行结束。

四种引用方式

  1. 强引用。new Object();就是强引用,强引用在发生GC/OOM 对象都不会被回收;
  2. 软引用。在发生OOM时对象会被回收;
  3. 弱引用。发生GC就会被回收(ThreadLocal);
  4. 虚引用。一般和引用队列配合使用,例如DIrectByteBuffer就是虚引用的典型,当引用状态要回收的时候,会将DIrectByteBuffer放入队列
  5. 堆外内存(DIrectByteBuffer)就是虚引用。