Java 多线程的相关知识,你了解吗?

Java中创建线程的方式

Future, FutureTask的实现方式暂未学习,待补充。

  1. 写一个类继承自Thread类,重写run方法。用start方法启动线程。
  2. 写一个类实现Runnable接口,实现run方法。用new Thread(Runnable target).start方法来启动。

线程的几种状态

答案参考自:

Java中的线程的状态总共有6种。

  1. 初始(NEW): 新创建了一个线程对象,但还没有调用start()方法。
  2. 运行(RUNNABLE): Java线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。
    线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取CPU的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得CPU时间片后变为运行中状态(running)。
  3. 阻塞(BLOCKED): 表示线程阻塞于锁。
  4. 等待(WAITING): 进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
  5. 超时等待(TIMED_WAITING): 该状态不同于WAITING,它可以在指定的时间后自行返回。
  6. 终止(TERMINATED): 表示该线程已经执行完毕。

这6种状态定义在Thread类的State枚举中,可查看源码进行一一对应。

线程状态图

线程状态图

状态详细说明

  1. 初始状态(NEW)
    实现Runnable接口和继承Thread可以得到一个线程类,new一个实例出来,线程就进入了初始状态。

  2. 运行状态(RUNNABLE)

    1. 就绪状态(RUNNABLEREADY)
      • 就绪状态只是说你有资格运行,调度程序没有挑选到你,你就永远是就绪状态。
      • 调用线程的start()方法,此线程进入就绪状态。
      • 当前线程sleep()方法结束,其他线程join()结束,等待用户输入完毕,某个线程拿到对象锁,这些线程也将进入就绪状态。
      • 当前线程时间片用完了,调用当前线程的yield()方法,当前线程进入就绪状态。
      • 锁池里的线程拿到对象锁后,进入就绪状态。
    2. 运行中状态(RUNNABLERUNNING)
      线程调度程序从可运行池中选择一个线程作为当前线程时线程所处的状态。这也是线程进入运行状态的唯一的一种方式。
  3. 阻塞状态(BLOCKED)
    阻塞状态是线程阻塞在进入synchronized关键字修饰的方法或代码块(获取锁)时的状态。

  4. 等待(WAITING)
    处于这种状态的线程不会被分配CPU执行时间,它们要等待被显式地唤醒,否则会处于无限期等待的状态。

  5. 超时等待(TIMED_WAITING)
    处于这种状态的线程不会被分配CPU执行时间,不过无须无限期等待被其他线程显示地唤醒,在达到一定时间后它们会自动唤醒。

  6. 终止状态(TERMINATED)
    当线程的run()方法完成时,或者主线程的main()方法完成时,我们就认为它终止了。这个线程对象也许是活的,但是它已经不是一个单独执行的线程。线程一旦终止了,就不能复生。
    在一个终止的线程上调用start()方法,会抛出java.lang.IllegalThreadStateException异常。

Thread#sleepObject#wait的区别

Thread#sleep

Thread.sleep(long millis),一定是当前线程调用此方法,当前线程进入TIMED_WAITING状态,但不释放对象锁millis后线程自动苏醒进入就绪状态。

作用:给其它线程执行机会的最佳方式。

Object#wait

当前线程调用对象的wait方法,当前线程释放对象锁,进入等待队列。

依靠notify/notifyAll唤醒或者wait(long timeout)timeout时间到自动唤醒。

总结

可以发现,两者的区别为当前的线程是否会释放对象锁。

Object#notifyObject#notifyAll的区别

Object#notify唤醒在此对象监视器上等待的单个线程选择是任意性的

Object#notifyAll唤醒在此对象监视器上等待的所有线程

锁池和等待池

答案参考自:

每个对象都有自己的“锁池”和“等待池”,用来存放线程。线程进入“锁池”,会处于竞争锁状态,当其他线程释放锁以后,才可能竞争到锁,然后执行同步块代码。线程进入”等待池“,会等待其他线程调用notify或者notifyAll方法,来进入“锁池”状态。

synchronized修饰的方法,在执行的时候,线程会被排序依次执行。这时,线程会被阻塞在对象的“锁池”中,只有一个线程会被执行。至于哪个线程被执行,需根据不同的虚拟机实现机制不同。

进入synchronized方法块的线程,会立即持有该对象的锁,并从“锁池”中移除。执行完毕,会释放锁,“锁池”中的线程依据一定规则会有一个线程依次执行该synchronized代码块。

进入synchronized代码块的线程,如果执行wait方法,就会释放改对象锁,该线程进入“等待池”,直到其他线程调用该对象的notify方法时,才有可能被唤醒继续执行后续代码,线程被唤醒以后,该线程从“等待池”中移除。

Thread#runThread#start的区别

答案参考自:

  1. 调用start方法方可启动线程,而run方法只是Thread的一个普通方法调用,还是在主线程里执行。

  2. 把需要并行处理的代码放在run方法中,start方法启动线程将自动调用 run方法,这是由JVM的内存机制规定的。并且run方法必须是public访问权限,返回值类型为void


线程死锁,如何有效的避免线程死锁?

答案参考自:

什么是线程死锁

死锁是指两个或两个以上的进程(线程)在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程(线程)称为死锁进程(线程)。

多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。

如上图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。

形成死锁的四个必要条件

  1. 互斥条件: 线程(进程)对于所分配到的资源具有排它性,即一个资源只能被一个线程(进程)占用,直到被该线程(进程)释放。
  2. 请求与保持条件: 一个线程(进程)因请求被占用资源而发生阻塞时,对已获得的资源保持不放。
  3. 不可剥夺条件: 线程(进程)已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  4. 循环等待条件: 当发生死锁时,所等待的线程(进程)必定会形成一个环路(类似于死循环),造成永久阻塞。

如何避免线程死锁

我们只要破坏产生死锁的四个条件中的其中一个就可以了

  1. 破坏互斥条件

    这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。

  2. 破坏请求与保持条件

    一次性申请所有的资源。

  3. 破坏不剥夺条件

    占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。

  4. 破坏循环等待条件

    靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。


如何保证线程安全

答案参考自:

  1. 互斥同步

    互斥同步是最常见的一种并发正确性保障手段。同步是指在多线程并发访问共享数据时,保证共享数据在同一时刻只被一个线程使用(同一时刻,只有一个线程在操作共享数据)。

    在java中,最基本的互斥同步手段就是synchronized关键字,synchronized关键字编译之后,会在同步块的前后分别形成 monitorenter 和 monitorexit 这两个字节码质量,这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。

    此外,ReentrantLock 也是通过互斥来实现同步。在基本用法上,ReentrantLock 与 synchronized 很相似,他们都具备一样的线程重入特性。

    互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也成为阻塞同步。从处理问题的方式上说,互斥同步属于一种悲观的并发策略,总是认为只要不去做正确地同步措施(例如加锁),那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要进行加锁。

  2. 非阻塞同步

    随着硬件指令集的发展,出现了基于冲突检测的乐观并发策略,通俗地说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采用其他的补偿措施。(最常见的补偿错误就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步。

    实现非阻塞同步的方式为CAS。

如何实现多线程中的同步

同步(synchronization)就是指一个线程访问数据时,其它线程不得对同一个数据进行访问,即同一时刻只能有一个线程访问该数据,当这一线程访问结束时其它线程才能对这它进行访问 。同步最常见的方式就是使用锁(Lock),也称为线程锁。锁是一种非强制机制,每一个线程在访问数据或资源之前,首先试图获取(Acquire)锁,并在访问结束之后释放(Release)锁。 在锁被占用时试图获取锁,线程会进入等待状态,直到锁被释放再次变为可用。

  • synchronized

  • Lock

volatile

答案参考自:

volatile 的作用

  • 保证了变量的内存可见性。
  • 禁止指令的重排序。

Java 内存模型(JMM)

JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。

JMM 的规定:

  • 所有的共享变量都存储于主内存。这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。

  • 每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。

  • 线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。

  • 不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。

JMM 的抽象示意图:

内存可见性

内存可见性是指当一个线程修改了某个变量的值,其它线程总是能知道这个变量变化。

也就是说,如果线程 A 修改了共享变量 V 的值,那么线程 B 在使用 V 的值时,能立即读到 V 的最新值。

内存可见行的解决方案

使用 volatile 关键字,或者加锁。

加锁

为什么加锁后就保证了变量的内存可见性了?

因为当一个线程进入 synchronized 代码块后,线程获取到锁,会清空本地内存,然后从主内存中拷贝共享变量的最新值到本地内存作为副本,执行代码,又将修改后的副本值刷新到主内存中,最后线程释放锁。

这里除了 synchronized 外,其它锁也能保证变量的内存可见性。

使用 volatile 关键字

使用 volatile 修饰共享变量后,每个线程要操作变量时会从主内存中将变量拷贝到本地内存作为副本,当线程操作变量副本并写回主内存后,会通过 CPU 总线嗅探机制告知其他线程该变量副本已经失效,需要重新从主内存中读取。

volatile 保证了不同线程对共享变量操作的可见性,也就是说一个线程修改了 volatile 修饰的变量,当修改后的变量写回主内存时,其他线程能立即看到最新值。

总结

使用 volatilesynchronized 锁都可以保证共享变量的可见性。相比 synchronized 而言,volatile 可以看作是一个轻量级锁,所以使用 volatile 的成本更低,因为它不会引起线程上下文的切换和调度。

volatile 无法像 synchronized 一样保证操作的原子性。

volatile 的原子性问题

原子性是指在一次操作或者多次操作中,要么所有的操作全部都得到了执行并且不会受到任何因素的干扰而中断,要么所有的操作都不执行。

在多线程环境下,volatile 关键字可以保证共享数据的可见性,但是并不能保证对数据操作的原子性。也就是说,多线程环境下,使用 volatile 修饰的变量是线程不安全的

要解决这个问题,我们可以使用锁机制,或者使用原子类(如 AtomicInteger)。

这里特别说一下,对任意单个使用 volatile 修饰的变量的读 / 写是具有原子性,但类似于 flag = !flag 这种复合操作不具有原子性。简单地说就是,单纯的赋值操作是原子性的

重排序

为了提高性能,在遵守 as-if-serial 语义(即不管怎么重排序,单线程下程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守。)的情况下,编译器和处理器常常会对指令做重排序。

一般重排序可以分为如下三种类型:

  • 编译器优化重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

happen-before 原则

上面提到了可以用 volatile 和 synchronized 来保证有序性。除此之外,JVM 还规定了先行发生原则,让一个操作无需控制就能先于另一个操作完成。

单一线程原则

Single Thread rule

在一个线程内,在程序前面的操作先行发生于后面的操作。

管程锁定规则

Monitor Lock Rule

一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。

volatile 变量规则

Volatile Variable Rule

对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。

线程启动规则

Thread Start Rule

Thread 对象的 start() 方法调用先行发生于此线程的每一个动作。

线程加入规则

Thread Join Rule

Thread 对象的结束先行发生于 join() 方法返回。

线程中断规则

Thread Interruption Rule

对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。

对象终结规则

Finalizer Rule

一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。

传递性

Transitivity

如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。

synchronized和volatile的区别?

答案参考自:

  1. volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。

  2. volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的

  3. volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性

  4. volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。

  5. volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化

为何不用volatile替代synchronized?

因为 volatile 不能保证原子性,当有多个线程修改同一个变量时,例如++操作,无法保证线程同步。


synchronized和Lock的比较

答案参考自:

两种锁的底层实现

Synchronized:底层使用指令码方式来控制锁的,映射成字节码指令就是增加来两个指令:monitorenter和monitorexit。当线程执行遇到monitorenter指令时会尝试获取内置锁,如果获取锁则锁计数器+1,如果没有获取锁则阻塞;当遇到monitorexit指令时锁计数器-1,如果计数器为0则释放锁。

Lock:底层是CAS乐观锁,依赖AbstractQueuedSynchronizer类,把所有的请求线程构成一个CLH队列。而对该队列的操作均通过Lock-Free(CAS)操作。

两种锁的区别

  1. Synchronized是关键字,内置语言实现,Lock是接口。

  2. Synchronized在线程发生异常时会自动释放锁,因此不会发生异常死锁。Lock异常时不会自动释放锁,所以需要在finally中实现释放锁。

  3. Lock是可以中断锁,Synchronized是非中断锁,必须等待线程执行完成释放锁。

  4. Lock可以使用读锁提高多线程读效率。

锁的分类

答案参考自:

公平锁/非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁。
非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。
对于Java ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。
对于Synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁。

可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。说的有点抽象,下面会有一个代码的示例。
对于Java ReentrantLock而言, 他的名字就可以看出是一个可重入锁,其名字是Re entrant Lock重新进入锁。
对于Synchronized而言,也是一个可重入锁。可重入锁的一个好处是可一定程度避免死锁。

1
2
3
4
5
6
7
8
synchronized void setA() throws Exception{
Thread.sleep(1000);
setB();
}

synchronized void setB() throws Exception{
Thread.sleep(1000);
}

上面的代码就是一个可重入锁的一个特点,如果不是可重入锁的话,setB() 可能不会被当前线程执行,可能造成死锁。

独享锁/共享锁

独享锁是指该锁一次只能被一个线程所持有。

共享锁是指该锁可被多个线程所持有。

对于JavaReentrantLock而言,其是独享锁。但是对于Lock的另一个实现类ReadWriteLock,其读锁是共享锁,其写锁是独享锁。

读锁的共享锁可保证并发读是非常高效的,读写,写读 ,写写的过程是互斥的。

独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。

对于Synchronized而言,当然是独享锁。

互斥锁/读写锁

上面讲的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。

互斥锁在Java中的具体实现就是ReentrantLock

读写锁在Java中的具体实现就是ReadWriteLock

乐观锁/悲观锁

乐观锁与悲观锁不是指具体的什么类型的锁,而是指看待并发同步的角度。

悲观锁认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题。

乐观锁则认为对于同一个数据的并发操作,是不会发生修改的。在更新数据的时候,会采用尝试更新,不断重新的方式更新数据。乐观的认为,不加锁的并发操作是没有事情的。

从上面的描述我们可以看出,悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升。

悲观锁在Java中的使用,就是利用各种锁。

乐观锁在Java中的使用,是无锁编程,常常采用的是CAS算法,典型的例子就是原子类,通过CAS自旋实现原子操作的更新。

分段锁

分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。

我们以ConcurrentHashMap来说一下分段锁的含义以及设计思想,ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap(JDK7与JDK8中HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLockSegment继承了ReentrantLock)。

当需要put元素的时候,并不是对整个HashMap进行加锁,而是先通过hashcode来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。

但是,在统计size的时候,可就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。

分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。

自旋锁

在Java中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。

典型的自旋锁实现的例子,可以参考自旋锁的实现

偏向锁/轻量级锁/重量级锁

这三种锁是指锁的状态,并且是针对Synchronized。在Java 5通过引入锁升级的机制来实现高效Synchronized。这三种锁的状态是通过对象监视器在对象头中的字段来表明的。

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。

轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。

重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。

锁的几种状态 / JDK 1.6 对 synchronized 的优化

答案参考自:

锁有四种状态:无锁状态、偏向锁、轻量级锁、重量级锁。具体细节可以参考上一个问题。

随着锁的竞争,锁的状态会从偏向锁到轻量级锁,再到重量级锁。而且锁的状态只有升级,没有降级。也就是只有偏向锁->轻量级锁->重量级锁,没有重量级锁->轻量级锁->偏向锁。

锁状态的改变是根据竞争激烈程度进行的,在几乎无竞争的条件下,会使用偏向锁,在轻度竞争的条件下,会由偏向锁升级为轻量级锁, 在重度竞争的情况下,会升级到重量级锁。


CAS原理

答案参考自:

CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。执行CAS操作的时候,将内存位置的值与预期原值比较,如果相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。

举个CAS操作的应用场景的一个例子,当一个线程需要修改共享变量的值。完成这个操作,先取出共享变量的值赋给A,然后基于A的基础进行计算,得到新值B,完了需要更新共享变量的值了,这个时候就可以调用CAS方法更新变量值了。

CAS的问题

CAS虽然很高效的解决原子操作,但是CAS仍然存在三大问题。ABA问题,循环时间长开销大和只能保证一个共享变量的原子操作。

  1. ABA问题。

    因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。从Java1.5开始JDK的atomic包里提供了一个类 AtomicStampedReference 来解决ABA问题。这个类的 compareAndSet 方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

  2. 循环时间长开销大。

    自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。

  3. 只能保证一个共享变量的原子操作。

    当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2, j=a,合并一下i, j=2a,然后用CAS来操作i, j。从Java1.5开始 JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作。


Java 线程池

答案参考自:

为什么Java用线程池

  1. 降低系统资源消耗,通过重用已存在的线程,降低线程创建和销毁造成的消耗;

  2. 提高系统响应速度,当有任务到达时,无需等待新线程的创建便能立即执行;

  3. 方便线程并发数的管控,线程若是无限制的创建,不仅会额外消耗大量系统资源,更是占用过多资源而阻塞系统或 OOM 等状况,从而降低系统的稳定性。线程池能有效管控线程,统一分配、调优,提供资源使用率;

  4. 更强大的功能,线程池提供了定时、定期以及可控线程数等功能的线程池,使用方便简单。

线程池参数

我们可以通过 ThreadPoolExecutor 来创建一个线程池。

1
ExecutorService service = new ThreadPoolExecutor(....);

下面我们就来看一下 ThreadPoolExecutor 中的一个构造方法。

1
2
3
4
5
6
7
8
9
public ThreadPoolExecutor(
int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler
)

ThreadPoolExecutor 参数含义

1. corePoolSize

线程池中的核心线程数,默认情况下,核心线程一直存活在线程池中,即便他们在线程池中处于闲置状态。除非我们将ThreadPoolExecutor的allowCoreThreadTimeOut属性设为true的时候,这时候处于闲置的核心线程在等待新任务到来时会有超时策略,这个超时时间由keepAliveTime来指定。一旦超过所设置的超时时间,闲置的核心线程就会被终止。

2. maximumPoolSize

线程池中所容纳的最大线程数,如果活动的线程达到这个数值以后,后续的新任务将会被阻塞。包含核心线程数+非核心线程数。

3. keepAliveTime

非核心线程闲置时的超时时长,对于非核心线程,闲置时间超过这个时间,非核心线程就会被回收。只有对 ThreadPoolExecutor 的 allowCoreThreadTimeOut 属性设为 true 的时候,这个超时时间才会对核心线程产生效果。

4. unit

用于指定 keepAliveTime 参数的时间单位。他是一个枚举,可以使用的单位有七种:

时间单位 变量名
TimeUnit.DAYS
小时 TimeUnit.HOURS
分钟 TimeUnit.MINUTES
TimeUnit.SECONDS
毫秒 TimeUnit.MILLISECONDS
微秒(千分之一毫秒) TimeUnit.MICROSECONDS
毫微秒(千分之一微秒) TimeUnit.NANOSECONDS

5. workQueue

线程池中保存等待执行的任务的阻塞队列。通过线程池中的execute方法提交的Runable对象都会存储在该队列中。我们可以选择下面几个阻塞队列。

阻塞队列 说明
ArrayBlockingQueue 基于数组实现的有界的阻塞队列,该队列按照FIFO(先进先出)原则对队列中的元素进行排序。
LinkedBlockingQueue 基于链表实现的阻塞队列,该队列按照FIFO(先进先出)原则对队列中的元素进行排序。
SynchronousQueue 内部没有任何容量的阻塞队列。在它内部没有任何的缓存空间。对于 SynchronousQueue 中的数据元素只有当我们试着取走的时候才可能存在。
PriorityBlockingQueue 具有优先级的无限阻塞队列。

我们还能够通过实现 BlockingQueue 接口来自定义我们所需要的阻塞队列。

6. threadFactory

线程工厂,为线程池提供新线程的创建。ThreadFactory是一个接口,里面只有一个newThread方法。 默认为DefaultThreadFactory类。

7. handler

是 RejectedExecutionHandler 对象,而 RejectedExecutionHandler 是一个接口,里面只有一个 rejectedExecution 方法。

当任务队列已满并且线程池中的活动线程已经达到所限定的最大值或者是无法成功执行任务,这时候 ThreadPoolExecutor 会调用 RejectedExecutionHandler 中的 rejectedExecution 方法。在 ThreadPoolExecutor 中有四个内部类实现了 RejectedExecutionHandler 接口。在线程池中它默认是 AbortPolicy ,在无法处理新任务时抛出 RejectedExecutionException 异常。

下面是在 ThreadPoolExecutor 中提供的四个可选值。

可选值 说明
CallerRunsPolicy 只用调用者所在线程来运行任务。
AbortPolicy 直接抛出RejectedExecutionException异常。
DiscardPolicy 丢弃掉该任务,不进行处理。
DiscardOldestPolicy 丢弃队列里最近的一个任务,并执行当前任务。

我们也可以通过实现RejectedExecutionHandler接口来自定义我们自己的handler。如记录日志或持久化不能处理的任务。

创建用例

OkHttp 中线程池的创建:

1
2
3
4
5
6
7
8
executorService = new ThreadPoolExecutor(
0,
Integer.MAX_VALUE,
60,
TimeUnit.SECONDS,
new SynchronousQueue<>(),
Util.threadFactory("OkHttp Dispatcher", false)
);

该线程池的核心线程数为 0,线程池最有能容纳 Integer.MAX_VALUE 个线程,且线程的空闲存活时间为 60s(可以理解为 okhttp 随时可以创建新的线程来满足需要。可以保证网络的 I/O 任务有线程来处理,不被阻塞)。

线程池的关闭

调用线程池的 shutdown()shutdownNow() 方法来关闭线程池

shutdown原理:将线程池状态设置成 SHUTDOWN 状态,然后中断所有没有正在执行任务的线程。

shutdownNow原理:将线程池的状态设置成 STOP 状态,然后中断所有任务(包括正在执行的)的线程,并返回等待执行任务的列表。

有几种线程池

Java中四种具有不同功能常见的线程池。他们都是直接或者间接配置 ThreadPoolExecutor 来实现他们各自的功能。这四种线程池分别是

  1. newFixedThreadPool
  2. newCachedThreadPool
  3. newScheduledThreadPool
  4. newSingleThreadExecutor

这四个线程池可以通过Executors类获取。


Android 多线程通信

  1. Handler 机制
  2. AsyncTask

TODO

  1. CLH 与 AQS
  2. 其他并发容器

Java 多线程的相关知识,你了解吗?
https://luoyuy.top/posts/834ec61b5019/
作者
LuoYu-Ying
发布于
2022年5月29日
许可协议