【Java常见问题】基础知识(六)多线程
并发程序设计是操作系统层面的概念,而许多高级语言都有在语言层面为这种机制提供相应的支持。在这篇文章中我们一起来学习一下Java的多线程。
实现Java多线程的方式?
- 通过Thread对象的API控制线程
- extends Thread类,重写run方法
- 实现Runnable接口,实现run方法(推荐)
一个类是否可以同时extends Thread和实现Runnable接口?
可以。只要是这两个的其中一个提供了run()方法所以不导致冲突就行。
run与start的区别? run可以类比为操作系统进程概念中的运行态,而start是就绪态。
-
通过Executor框架中的功能类Callable对象
- 实现Callable接口,重写call()方法
-
使用线性池
线程池
Executor接口
以下是官方文档对该接口作用的描述:
ExecutorService
ExecutorService提供了一系列对Executor生命周期的管理。
文档中提到的内存一致性影响:在将 Runnable 对象提交到执行程序之前,线程中的操作在其执行开始之前已经发生,可能在另一个线程中发生。
ThreadPoolExecutor
笔者找到的最全的构造方法中包含如下参数:
其中:
corePoolSize
- the number of threads to keep in the pool, even if they are idle, unlessallowCoreThreadTimeOut
is set 线程池的基本大小maximumPoolSize
- the maximum number of threads to allow in the pool 线程池的最大容量keepAliveTime
- when the number of threads is greater than the core, this is the maximum time that excess idle threads will wait for new tasks before terminating. 过剩的线程的过期时限unit
- the time unit for thekeepAliveTime
argument 线程过期时限的最小计量单位workQueue
- the queue to use for holding tasks before they are executed. This queue will hold only theRunnable
tasks submitted by theexecute
method. 用于存放被execute方法提交的就绪任务的队列threadFactory
- the factory to use when the executor creates a new thread 执行者创建线程时所用到的工厂handler
- the handler to use when execution is blocked because the thread bounds and queue capacities are reached 当执行被阻塞时(如到达线程数量上限)所用到的句柄
线程池工作流程?
- 若现有线程数少于限额corePoolSize,直接创建新线程以执行任务
- 若大于限额,先暂时把任务存到workQueue中等待执行
- 若workQueue也满,若线程数量小于maxinumPoolSize,创建新线程;否则拒绝处理。
Executors类中四种常用的线程池应用
实现多线程同步的具体方式?
- synchronized
- 方法声明前加入
- 代码块{}为synchronized
- wait()与notify() 在synchronized代码块执行期间,线程可通过wait()进入等待状态,且通过notify()去通知其他线程竞争锁。
wait和notify是由虚拟机本地的c代码实现的。
wait与sleep的区别?
- sleep()可理解为是线程针对自身的管理机制;而wait()则涉及到进(线)程间通信,与notify()/notifyall搭配使用,需要由其他线程唤醒或开发人员设定规定时间后醒来。
- 调用sleep不会释放锁,调用wait会
- sleep使用场景更通用,比如单纯想让程序慢一些;wait有并发编程的特殊语境。
- sleep需要捕获异常,wait-notify不需要
sleep()与yield()的区别?
yield有放弃、让出之意。因此它和sleep在语义上就有很大不同:
- 进入sleep的线程对应于操作系统中的阻塞态(等待态),而yield的依然很活跃,只是重新进入了就绪态。
- 一个线程sleep后,其他线程无论优先级高低都有机会;而yield只是给相同或更高优先级的线程让步。
- sleep声明会抛出异常,yield不会。
- sleep的可移植性更好
- Lock
回顾:操作系统中造成死锁需要同时满足哪几个条件?
- 互斥
- 不可剥夺
- 已经有了资源,又去申请别的资源,但别的资源一直得不到释放,自己也不愿意释放现有的资源
- 环路等待
synchronized与lock的异同?
synchronized | lock | |
---|---|---|
锁机制 | 自动释放,获取多个锁时先进的后出 | 手动在finally块中释放,tryLock()可以非阻塞方式获取锁 |
性能 | 低并发时syn优于lock,高并发时syn性能下降快 | JDK5开始的ReentranLock性能相对稳定 |
最值得关注的Java Lock
- ReentrantLock(重入锁、递归锁)
- ReentrantReadWriteLock 将锁进一步细分为读、写锁以提高并发程度
Java代码中如何终止线程?
官方已经不太推荐使用的方法:
- stop
执行stop的线程会释放相应资源,而受资源保护的对应对象的状态不一致且被其他线程感知,可能会导致程序执行的不确定性,且难以排查。
- suspend
suspend不释放锁,容易导致死锁
参考思路:
基本思路就是让线程离开run(),自行进入Dead状态。
而如何让它离开又分具体情况。
- 对症下药
比如,使用readLine等待输入信息,而线程处于阻塞状态,为了让它离开run(),可以close掉对应的流,从而引发IOException,让run()捕获异常而结束线程。(一般是针对处于非运行态的线程来进行对症处理)
- 使用中断
通过使用中断来抛出InterruptedException,让run()捕获异常而结束线程。但这种方法仅适用于程序处在运行态时。
如何捕获线程抛出的异常?
线程抛出的异常无法通过try-catch捕获(线程是什么?它也是由独立的代码片段组成的)。但从JDK5开始,官方提供了Thread.UncaughtExceptionHandler接口,可以实现其中的uncaughtException()方法并调用。
其他可关注的应用点
ThreadLocal
- 应用场景:多线程环境下,某一数据可一直都由其中一个线程访问
以下是官方文档对ThreadLocal的介绍:
如何理解呢?顾名思义,我认为是这个值与特定的线程绑定了(比如通过用户id、事务id等),单个线程修改的是自己本地可见的值的变量,不影响其他线程对应的变量。不同的线程读到的变量的值是不同的。
实现原理
- 线程的对象存储在堆中,对象的引用存储在栈中。
- 每个线程内部维护一个ThreadLocalMap,key是对ThreadLocal的弱引用,value存储了真正的对象。
- 当ThreadLocal的set/get被调用时,JVM会根据栈中线程的引用ThreadRef找到它在堆中的实例,若对应的map实例未被创建则创建,否则利用当前的key进行存取操作。
ThreadLocal为什么会导致内存泄露?
因为key是弱引用,在GC时可能会被回收,导致对应value无法被访问。但是Entry本身无法被回收,很多线程长期存在,导致了内存的泄露。
值得留意的特性
jdk8开始新增了ThreadLocal的一个方法:
CountDownLatch
- 应用场景: 直译就是:让一个或一组线程等待指定的计数时间,直到另一组线程完成了所需要执行的一系列操作之后。
- 下面这一段指出了三大关键信息:
- 计数期间,处于阻塞状态的是await()方法,因此这后续的代码要等指定任务执行完后才执行。
- 计数器不可重用
- 如有需要可以用CyclicBarrier
CyclicBarrier
官网的示例代码也很详细,感兴趣的uu可以看看。
ForkJoinPool
Fork-Join是自JDK7以来的一个高效的小工具,顾名思义,具有和MapReduce类似的特点:先将任务拆解开来分别完成,最后再合并,以提高执行效率。
以下是关于它的两个具体应用的类:
ForkJoinTask
从上述文字中可获取以下信息:
- 它是一个轻量级的线程,可看作是ForkJoinPool中执行的task。
- 它主要的方法有:fork(),join(),invote()/invoteAll()
- 它实现了Future接口。
由上述描述可知:
- 它有两个子类:RecursiveAction不返回结果;RecursiveTask返回结果
ForkJoinPool
上述内容揭示了ForkJoin不同于其他执行任务的特性:work-stealing机制。指的是线程把自己工作队列中的任务完成好了以后,还会偷偷地把别的线程的任务也做了(抢着干活)
上述内容表明: 有两种池可选:commonPool和ForkJoinPool。
- 前者在大多数情况下使用,但是资源利用率会比较低(因为线程不被使用时被回收得慢);
- 针对需要指定或自定义池的应用,可以将ForkJoinPool构造成指定的并行级别。
两者的调用方法写法也是略有不同的: 下图表明:ForkJoinPool支持同步和异步两种方式。默认的是同步,管理工作线程使用的是栈形式;而异步使用的是队列形式。
关于CAS(compare and swap)
算法原理
以下是伪代码:
function cas(p : pointer to int, old : int, new : int) returns bool {
if *p ≠ old {
return false
}
*p ← new
return true
}
即修改值的时候,先判断它有没有被修改过,如果没有被修改过则修改它并返回成功;否则返回失败。它是用于多线程实现同步的原子命令。
几大问题
- 原子问题涉及到CPU指令顺序问题,Java能否控制到这一级别?
Java有一个非公开的类sun.misc.Unsafe,封装了一系列原子化操作,不过这些方法本质上也是由C/C++实现的。
- 多线程环境下如何保证结点交换时的线程安全?
要回答这一问题,就是要回答xx方法如何保证线程安全的三大特性:原子性、有序性、可见性。
AbstractQueuedSynchronizer(AQS)使用CAS机制保障原子性,使用volatile保证head和tail结点在执行中的有序性和可见性。
- 如何解决CAS引起的ABA漏洞问题?
ABA漏洞问题,指的是由于若同一个值在前后阶段相同,CAS则认为它没有被修改,但实际上可能已经被修改。比如当一个栈中有BA两个元素(A为栈顶),过程中另一个线程将AB出栈,又将CA入栈,则原来的线程再次读取值时还以为栈未被修改,它想将B变成栈顶就会出错。
解决方案是留有一个“版本号”,每次更新数据后版本号+1,以此比对数据是否被修改过。
转载自:https://juejin.cn/post/7217740185424052279