史上最全JAVA多线程文档 – 从入门到精通

Java
262
0
0
2023-05-21
标签   Java多线程

目录:

一、 多线程 是什么?

进程

进程 vs 线程

二、为什么要用多线程?

三、怎么实现多线程?

1、继承Thread类

2、实现Runnable接口

3、通过Callable和Future创建线程:

四、多线程如何管理

1、线程生命周期管理

2、线程优先级

3、守护线程

4、线程的协作

5、 线程池 (重点)

五、多线程会到来什么问题?怎么解决?

1、线程安全问题

2、 线程 活性问题

3、上下文切换问题

4、可靠性问题

六、底层原理(重点)

1、多线程内存模型

2、并发锁机制

一、多线程是什么?

现代操作系统(Windows, macOS ,Linux)都可以执行多任务。多任务就是同时运行多个任务,例如:ie、QQ、微信、360等, CPU 执行代码都是一条一条顺序执行的,但是,即使是单核cpu,也可以同时运行多个任务。因为操作系统执行多任务实际上就是让CPU对多个任务轮流交替执行。

例如,假设我们有语文、数学、英语3门作业要做,每个作业需要30分钟。我们把这3门作业看成是3个任务,可以做1分钟语文作业,再做1分钟数学作业,再做1分钟英语作业,这样轮流做下去,在某些人眼里看来,做作业的速度就非常快,看上去就像同时在做3门作业一样。类似的,操作系统轮流让多个任务交替执行,例如,让IE执行0.001秒,让QQ执行0.001秒,再让微信执行0.001秒,在人看来,CPU就是在同时执行多个任务。

即使是多核CPU,因为通常任务的数量远远多于CPU的核数,所以任务也是交替执行的。

进程

在计算机中,我们把一个任务称为一个进程,浏览器就是一个进程,视频播放器是另一个进程,类似的,音乐播放器和Word都是进程。

某些进程内部还需要同时执行多个子任务。例如,我们在使用Word时,Word可以让我们一边打字,一边进行拼写检查,同时还可以在后台进行打印,我们把子任务称为线程。

进程和线程的关系就是:一个进程可以包含一个或多个线程,但至少会有一个线程。

史上最全JAVA多线程文档 - 从入门到精通

操作系统调度的最小任务单位其实不是进程,而是线程。常用的Windows、Linux等操作系统都采用抢占式多任务,如何调度线程完全由操作系统决定,程序自己不能决定什么时候执行,以及执行多长时间。

因为同一个应用程序,既可以有多个进程,也可以有多个线程,因此,实现多任务的方法,有以下几种:

多进程模式(每个进程只有一个线程):

史上最全JAVA多线程文档 - 从入门到精通

多线程模式(一个进程有多个线程):

史上最全JAVA多线程文档 - 从入门到精通

多进程+多线程模式(复杂度最高):

史上最全JAVA多线程文档 - 从入门到精通

进程 vs 线程

进程和线程是包含关系,但是多任务既可以由多进程实现,也可以由单进程内的多线程实现,还可以混合多进程+多线程。具体采用哪种方式,要考虑到进程和线程的特点。


优点


缺点


多进程


稳定性高(一个进程崩溃不会影响其他进程,而在多线程的情况下,任何一个线程崩溃会直接导致整个进程崩溃)


开销大(创建进程比创建线程开销大)



通信慢(进程间通信比线程间通信要慢,因为线程间通信就是读写同一个变量,速度很快。)


多线程


开销小、通信快


稳定性差、



复杂度高(多线程经常需要读写共享数据,并且需要同步。例如,播放电影时,就必须由一个线程播放视频,另一个线程播放音频,两个线程需要协调运行,否则画面和声音就不同步。因此,多线程编程的复杂度高,调试更困难)


java 语言内置了多线程支持:一个Java程序实际上是一个JVM进程, JVM 进程用一个主线程来执行main()方法,在main()方法内部,我们又可以启动多个线程。此外,JVM还有负责垃圾回收的其他工作线程等。因此,对于大多数Java程序来说,我们说多任务,实际上是说如何使用多线程实现多任务。

二、为什么要用多线程?

①、为了更好的利用cpu的资源, 提高程序的效率。 如果只有一个线程,则第二个任务必须等到第一个任务结束后才能进行,如果使用多线程则在主线程执行任务的同时可以执行其他任务,而不需要等待;

②、进程之间不能共享数据,线程可以;

③、系统创建进程需要为该进程重新分配系统资源,创建线程代价比较小;

④、Java语言内置了多线程功能支持,简化了java多线程编程。

三、怎么实现多线程?

1、继承Thread类

步骤:

①、定义类继承Thread;

②、 复写 Thread类中的run方法;

目的:将自定义代码存储在run方法,让线程运行

③、调用线程的start方法:

该方法有两步:启动线程,调用run方法。

史上最全JAVA多线程文档 - 从入门到精通

2、实现Runnable接口

步骤:

①、定义类实现Runnable接口

②、覆盖Runnable接口中的run方法

将线程要运行的代码放在该run方法中。

③、通过 Thread 类建立线程对象。

④、将Runnable接口的子类对象作为实际参数传递给Thread类的构造函数。

自定义的run方法所属的对象是Runnable接口的子类对象。所以要让线程执行指定对象的run方法就要先明确run方法所属对象

⑤、调用Thread类的start方法开启线程并调用Runnable接口子类的run方法。

史上最全JAVA多线程文档 - 从入门到精通

3、通过Callable和Future创建线程:

步骤:

①、创建Callable接口的实现类,并实现call()方法,改方法将作为线程执行体,且具有返回值。

②、创建Callable实现类的实例,使用FutrueTask类进行包装Callable对象,FutureTask对象封装了Callable对象的call()方法的返回值

③、使用FutureTask对象作为Thread对象启动新线程。

④、调用FutureTask对象的get()方法获取子线程执行结束后的返回值。

史上最全JAVA多线程文档 - 从入门到精通


优点


缺点


Thread


代码简洁方便(直接就可以start,不需要任何转换)


缺少灵活性(继承了Thread类后由于 Java 的单继承机制,就不可以继承其他的类了)


Runnable


灵活性高、资源共享(多个线程可以操作统一资源)


代码较简洁


Callable


灵活性高、有返回值、可以抛出异常


代码繁杂


总结:

实际开发中可能有更复杂的代码实现,需要继承其他的类,所以平时更推荐通过实现接口来实现多线程,也就是通过第二或第三种方式来实现,这样能保持代码灵活和解耦。

而选择第二还是第三种方式,则要根据run()方法是不是需要返回值或者捕获异常来决定,如果不需要,可以选择用第二种方式实现,代码更简洁。

龟兔赛跑案例代码:

史上最全JAVA多线程文档 - 从入门到精通

四、多线程如何管理

1、线程生命周期管理

1)线程状态

史上最全JAVA多线程文档 - 从入门到精通

  • NEW
  • 尚未启动的线程处于此状态。
  • RUNNABLE
  • 在 Java虚拟机 中执行的线程处于此状态。
  • B Lock ED
  • 被阻塞等待监视器锁定的线程处于此状态。
  • WAITING
  • 正在等待另一个线程执行特定动作的线程处于此状态。
  • TIMED_WAITING
  • 正在等待另一个线程执行动作达到指定等待时间的线程处于此状态。
  • TERMINATED
  • 已退出的线程处于此状态。

进入方法


退出方法


没有设置 Timeout 参数的 Object.wait() 方法


Object.notify() / Object.notifyAll()


没有设置 Timeout 参数的 Thread.join() 方法


被调用的线程执行完毕


LockSupport.park() 方法



Thread.sleep() 方法


时间结束


设置了 Timeout 参数的 Object.wait() 方法


时间结束 / Object.notify() / Object.notifyAll()


设置了 Timeout 参数的 Thread.join() 方法


时间结束 / 被调用的线程执行完毕


LockSupport.parkNanos() 方法



LockSupport.parkUntil() 方法



2)线程停止

建议线程正常停止 -> 利用次数,不建议死循环

建议使用标志位 -> 设置一个标志位

不要使用stop、destory等过时或 JDK 官方不建议使用方法

史上最全JAVA多线程文档 - 从入门到精通

3)线程睡眠

网络延时可以放大问题的发生性、倒计时等等作用

每一个对象都有一个锁,sleep不会释放锁

史上最全JAVA多线程文档 - 从入门到精通

4)线程礼让

礼让不一定成功,要看CPU的心情

史上最全JAVA多线程文档 - 从入门到精通

5)线程强制执行

史上最全JAVA多线程文档 - 从入门到精通

2、线程优先级

Java提供一个线程调度器来监控程序中启动后进入就绪状态的所有线程,线程调度器按照优先级决定应该调度哪个线程来执行。

线程优先级用数字表示,范围为1~10,可以用getPriority(),setPriority(int xxx)来改变或获取优先级。

史上最全JAVA多线程文档 - 从入门到精通

优先级低只是意味着获得调度概率低,并不是优先级低就不会被调用了,还是需要看CPU的心情。

3、守护线程

线程分为用户线程和守护线程

虚拟机不用等待守护线程执行完毕,但必须确保用户线程执行完毕

应用场景 : 监控内存、后台记录操作日志、垃圾回收等

比如:用守护线程把死循环的线程终止

史上最全JAVA多线程文档 - 从入门到精通

4、线程的协作

应用场景:比如一个仓库生产一批产品,消费者去消费,消费完通知仓库再生产。

方法名


作用


Wait()


表示线程一直等待,直到其他线程通知,与sleep不同,会释放锁


Notify()


唤醒一个处于等待状态的线程


NotifyAll()


唤醒所有等待状态的线程


比如仓库生产线程消费完一批产品,放在缓冲区,通知消费者线程去消费,消费者线程不停的消费缓冲区的产品,当达到一定库存余额的时候,通知仓库线程去生产。

5、线程池(重点)

1)背景:

在一个应用程序中,我们需要多次使用线程,也就意味着,我们需要多次创建并销毁线程。而创建并销毁线程的过程势必会消耗内存。而在Java中,内存资源是及其宝贵的,所以,我们就提出了线程池的概念。

2)描述:

Java中开辟出了一种管理线程的概念,这个概念叫做线程池,从概念以及应用场景中,我们可以看出,线程池的好处,就是可以方便的管理线程,也可以减少内存的消耗,线程可复用。

(1)线程池类型:

四种常见的线程池

CachedThreadPool:可缓存的线程池,该线程池中没有核心线程,非核心线程的数量为Integer.max_value,就是无限大,当有需要时创建线程来执行任务,没有需要时回收线程,适用于耗时少,任务量大的情况。

SecudleThreadPool:周期性执行任务的线程池,按照某种特定的计划执行线程中的任务,有核心线程,但也有非核心线程,非核心线程的大小也为无限大。适用于执行周期性的任务。

SingleThreadPool:只有一条线程来执行任务,适用于有顺序的任务的应用场景。

FixedThreadPool:定长的线程池,有核心线程,核心线程的即为最大的线程数量,没有非核心线程(举个栗子,如果一间澡堂子最大只能容纳20个人同时洗澡,那么后面来的人只能在外面排队等待。如果硬往里冲,那么只会出现一种情景,摩擦摩擦…)

虽然有四种类型,但是他们底层的实现是一样的,都是通过 new ThreadPoolExecutor(),只是传的参数不一样。

(2)线程池的主要参数:

public    ThreadPoolExecutor  (  int   corePoolSize,  int   maximumPoolSize,  long   keepAliveTime, TimeUnit unit, BlockingQueue  <  Runnable  >         workQueue) { 
    this  (corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, 
    Executors.  defaultThreadFactory  (), default handler ); 
} 

corePoolSize(核心线程池大小): 当向线程池提交一个任务时,若线程池已创建的线程数小于corePoolSize,即便此时存在空闲线程,也会通过创建一个新线程来执行该任务,直到已创建的线程数大于或等于corePoolSize时,(除了利用提交新任务来创建和启动线程(按需构造),也可以通过 prestartCoreThread() 或 prestartAllCoreThreads() 方法来提前启动线程池中的基本线程。)

maximumPoolSize(线程池最大大小): 线程池所允许的最大线程个数。当队列满了,且已创建的线程数小于maximumPoolSize,则线程池会创建新的线程来执行任务。另外,对于无界队列,可忽略该参数。

keepAliveTime(线程存活保持时间) 当线程池中线程数大于核心线程数时,线程的空闲时间如果超过线程存活时间,那么这个线程就会被销毁,直到线程池中的线程数小于等于核心线程数。

workQueue(任务队列): 当线程超过最大线程池大小时,需要等待,用于传输和保存等待执行任务的阻塞队列。

threadFactory(线程工厂): 用于创建新线程。threadFactory创建的线程也是采用new Thread()方式,threadFactory创建的线程名都具有统一的风格:pool-m-thread-n(m为线程池的编号,n为线程池内的线程编号)。

handler(线程饱和策略): 当线程池和队列都满了,再加入线程会执行此策略。这个策略默认情况下是AbortPolicy,表示无法处理新任务时抛出异常。在JDK 1.5中Java线程池框架提供了以下4种策略。

AbortPolicy:直接抛出异常。

CallerRunsPolicy:只用调用者所在线程来运行任务。

DiscardOldestPolicy:丢弃队列里最近的一个任务,并执行当前任务。

DiscardPolicy:不处理,丢弃掉。

当然,也可以根据应用场景需要来实现RejectedExecutionHandler接口自定义策略。如记录日志或持久化存储不能处理的任务。

3)如何用线程池?

虽然线程池有四种类型,但是他们底层的实现是一样的,都是通过 new ThreadPoolExecutor(),只是传的参数不一样。

4)运行原理:

当向线程池提交任务后,线程池会按下图所示流程去处理这个任务

史上最全JAVA多线程文档 - 从入门到精通

1)线程池判断核心线程池里的线程是否都在执行任务。如果不是,则创建一个新的工作线程来执行任务。如果核心线程池里的线程都在执行任务,则进入下个流程。

2)线程池判断工作队列是否已经满。如果工作队列没有满,则将新提交的任务存储在这个工作队列里。如果工作队列满了,则进入下个流程。

3)线程池判断线程池的线程是否都处于工作状态。如果没有,则创建一个新的工作线程来执行任务。如果已经满了,则交给饱和策略来处理这个任务。

对应到代码层面就是ThreadPoolExecutor执行execute()方法。如下图所示:

史上最全JAVA多线程文档 - 从入门到精通

1)如果当前运行的线程少于corePoolSize,则创建新线程来执行任务(注意,执行这一步骤需要获取全局锁)。

2)如果运行的线程等于或多于corePoolSize,则将任务加入BlockingQueue。

3)如果无法将任务加入BlockingQueue(队列已满),则创建新的线程来处理任务(注意,执行这一步骤需要获取全局锁)。

4)如果创建新线程将使当前运行的线程超出maximumPoolSize,任务将被拒绝,并调用RejectedExecutionHandler.rejectedExecution()方法。根据不同的拒绝策略去处理。

ThreadPoolExecutor 采取上述步骤的总体设计思路,是为了在执行execute()方法时,尽可能地避免获取全局锁(那将会是一个严重的可伸缩瓶颈)。

在ThreadPoolExecutor完成预热之后(当前运行的线程数大于等于corePoolSize),几乎所有的execute()方法调用都是执行步骤2,而步骤2不需要获取全局锁。

5)应用场景:

快速响应用户请求是线程池十分常见的业务场景。具体来讲就是用户发起的实时请求,服务追求响应时间。比如说用户要查看一个商品的信息,那么我们需要将商品维度的一系列信息如商品的价格、优惠、库存、图片等等聚合起来,展示给用户。

除此之外,快速处理批量任务也是我们会遇到的业务场景。离线的大量计算任务,需要快速执行。比如说,统计某个报表,需要计算出全国各个门店中有哪些商品有某种属性,用于后续营销策略的分析,那么我们需要查询全国所有门店中的所有商品,并且记录具有某属性的商品,然后快速生成报表。

五、多线程会到来什么问题?怎么解决?

1、线程安全问题

多线程共享资源时,如果没有采取正确的并发访问控制措施,就可能会产生数据不一致的问题,如读取脏数据、丢失数据更新等。

发生原因:

由于同一进程的多个线程共享同一块存储空间,在并发情况下,会存在抢占资源的安全问题。

场景

1)买票场景

史上最全JAVA多线程文档 - 从入门到精通

2)List不安全

史上最全JAVA多线程文档 - 从入门到精通

3)取钱

此时正常情况,由于执行够快,不存在并发问题,不会出现安全问题:

史上最全JAVA多线程文档 - 从入门到精通

用sleep把问题放大后:

史上最全JAVA多线程文档 - 从入门到精通

解决:

队列 + 锁,比如排队上厕所,只有大家都按照队形排队,而且上厕所的人把门关了,这样才是安全的。

同步( synchronized )

运行原理

Monitor机制:

史上最全JAVA多线程文档 - 从入门到精通

具体步骤:

  1. 线程访问同步代码,需要获取monitor锁
  2. 线程被jvm托管
  3. jvm获取充当临界区锁的java对象
  4. 根据java对象对象头中的重量级锁 ptr_to_heavyweight_monitor指针找到objectMonitor
  5. 将当前线程包装成一个ObjectWaiter对象
  6. 将ObjectWaiter假如_cxq(ContentionList)队列头部
  7. _count++
  8. 如果owner是其他线程说明当前monitor被占据,则当前线程阻塞。如果没有被其他线程占据,则将owner设置为当前线程,将线程从等待队列中删除,count–。
  9. 当前线程获取monitor锁,如果条件变量不满足,则将线程放入WaitSet中。当条件满足之后被唤醒,把线程从WaitSet转移到EntrySet中。
  10. 当前线程临界区执行完毕
  11. Owner线程会在unlock时,将ContentionList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为OnDeck线程(一般是最先进去的那个线程)。Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交个OnDeck,OnDeck需要重新竞争锁

简单来说:synchronized通过Monitor来实现线程同步,Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步。

处理方式

  1. 同步方法

比如买票的场景,用synchronized处理

史上最全JAVA多线程文档 - 从入门到精通

  1. 同步块

同步块可以锁任何对象,要注意,一定要锁变化的量,比如取现的账号

史上最全JAVA多线程文档 - 从入门到精通

比如:list加的代码块

史上最全JAVA多线程文档 - 从入门到精通

注意

用synchronized指令的执行是JVM通过调用操作系统的互斥mutex来实现的,被阻塞的线程会被挂起,等待重新调度,会导致多次上下文的切换—“用户态和内核态”两个态之间来回切换,对性能有较大影响

锁(Lock)

史上最全JAVA多线程文档 - 从入门到精通

总结:

当然锁也会带来一些问题:

  1. 一个线程持有锁会导致其他所有需要此锁的线程挂起
  2. 在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题
  3. 如果一个优先级高的线程等待一个优先级低的线程释放锁,会导致优先级倒置,引起性能问题(比如一个撒尿的等待拉屎的)

优化

更优方案参考第六节的内容(volatile和CAS等)

2、线程活性问题

由于程序自身缺陷或者由资源稀缺性导致线程一直处于非Runnable状态。

1) 死锁 (Deadlock)

发生原因:

多个线程各自占有一些共享资源,并且互相等待其他线程占用的资源才能运行,而导致两个或者多个线程都在等待对方释放资源,都停止执行的情形。某一个同步块同事拥有“两个以上对象的锁”时,就可能会发生“死锁”问题。

产生死锁的四个必要条件:

1)互斥: 一个资源每次只能被一个进程使用。

2)占有且等待: 一个进程本身占有资源(一种或多种),同时还有资源未得到满足,正在等待其他进程释放该资源。

3)不可抢占: 别人已经占有了某项资源,你不能因为自己也需要该资源,就去把别人的资源抢过来。

4)循环等待: 存在一个进程链,使得每个进程都占有下一个进程所需的至少一种资源。

注意:这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。

场景:

1、抢占口红镜子

史上最全JAVA多线程文档 - 从入门到精通

1)互斥: 两个进程同时抢占,但是口红和镜子一次只能给一个进程使用。

2)占有且等待: 小明进程占用了口红,但是还有镜子没有拿到;小红进程占用了镜子,但是还有口红没拿到

3)不可抢占: 在资源没使用完之前,不能强行抢占,比如小红还在占用镜子,小明不能强行剥夺。

4)循环等待: 小明想要小红的镜子,小红想要小明的口红

解决:

1、预防(事前)

设法至少破坏产生死锁的四个必要条件之一,严格的防止死锁的出现。 由于互斥条件是非共享资源所必须的,不仅不能改变,还应加以保证,所以,主要是破坏产生死锁的其他三个条件。

  • 破坏“占有且等待”条件

比如: 锁加时限: 我可以在每个获取锁的时候加上个时限,如果超过某个时间就放弃获取锁之类的。

——抢占口红镜子,如果把锁口红和镜子的时间设置一个时限,到某个时限就放弃,那就不会存在死锁

  • 破坏“不可抢占”条件

比如: 死锁检测: 按线程间获取锁的关系检测线程间是否发生死锁,如果发生死锁就执行一定的策略,如终断线程或回滚操作等。

——抢占口红镜子,如果

  • 破坏“循环等待”条件

比如: 按顺序加锁: 每个线程都按同一个的加锁顺序,这样就不会出现死锁。

——抢占口红镜子,如果两个线程都是按照先锁口红,再锁镜子,就不会存在死锁的问题

2、避免(事中)

避免死锁不严格限制产生死锁的必要条件的存在,因为即使死锁的必要条件存在,也不一定发生死锁。

锁加时限: 给每一个访问线程增加访问时效,若一个线程没有在给定的时限内成功获得所有需要的锁,则会进行回退并释放所有已经获得的锁,然后等待一段随机的时间再重试。

——抢占口红镜子,如果把锁口红和镜子的时间设置一个时限,到某个时限就放弃,那就不会存在死锁

史上最全JAVA多线程文档 - 从入门到精通

按顺序加锁: 每个线程都按同一个的加锁顺序,这样就不会出现死锁。

——抢占口红镜子,如果两个线程都是按照先锁口红,再锁镜子,就不会存在死锁的问题

方法1: 无论选择0,还是1,都是先锁口红再锁镜子。

史上最全JAVA多线程文档 - 从入门到精通

方法2: 定义的对象中持有ReentranceLock ,通过ReentranceLock中的tryLock的方式来获取锁.

史上最全JAVA多线程文档 - 从入门到精通

死锁检测: 按线程间获取锁的关系检测线程间是否发生死锁,如果发生死锁就执行一定的策略,如终断线程或回滚操作等。

——详见3)

3、检测和恢复(事后,已发生)

预防和避免死锁系统开销大且不能充分利用资源,更好的方法是不采取任何限制性措施,而是提供检测和解脱死锁的手段,这就是死锁检测和恢复

1)通过jConsole检测死锁

通过jConsole连接上进程后,点击线程,会看到检测死锁按钮。

史上最全JAVA多线程文档 - 从入门到精通

死锁界面会显示只有锁的线程,还有持有锁的位置

史上最全JAVA多线程文档 - 从入门到精通

2)通过jvisualvm检测死锁

史上最全JAVA多线程文档 - 从入门到精通

点击线程Dump后,会有死锁线程的提示。也可以将线程Dump信息复制出来,通过一些第三方工具进行分析,比如:fastthread

史上最全JAVA多线程文档 - 从入门到精通

然而生产环境,是不允许我们在本地使用可视化工具进行连接的。

3)通过arthas检测

通过命令:thread -b

史上最全JAVA多线程文档 - 从入门到精通

4)通过jdk的工具:jstack命令

stack -l 54726

史上最全JAVA多线程文档 - 从入门到精通

也可以通过

jstack -l 54726 >> /data/logs/stack.txt

将线程堆栈信息存入本地文件,便于分析

2)锁死(Lockout)

类似睡美人故事,如果王子挂掉了,没有叫醒她,那么她就一直在睡觉。

3)活锁(Livelock)

活锁恰恰与死锁相反,死锁是大家都拿不到资源都占用着对方的资源,而活锁是拿到资源却又相互释放不执行。当多线程中出现了相互谦让,都主动将资源释放给别的线程使用,这样这个资源在多个线程之间跳动而又得不到执行

4)饥饿(Starvation)

多线程执行中有线程优先级这个东西,优先级高的线程能够插队并优先执行,这样如果优先级高的线程一直抢占优先级低线程的资源,导致低优先级线程无法得到执行,这就是饥饿;。当然还有一种饥饿的情况,一个线程一直占着一个资源不放而导致其他线程得不到执行,与死锁不同的是饥饿在以后一段时间内还是能够得到执行的,如那个占用资源的线程结束了并释放了资源。

3、上下文切换问题

1)描述

处理器从执行一个线程切换到执行另外一个线程。即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停地切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。

CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。

这就像我们同时读两本书,当我们在读一本英文的技术书时,发现某个单词不认识,于是便打开中英文字典,但是在放下英文技术书之前,大脑必须先记住这本书读到了多少页的第多少行,等查完单词之后,能够继续读这本书。这样的切换是会影响读书效率的,同样上下文切换也会影响多线程的执行速度。

史上最全JAVA多线程文档 - 从入门到精通

测试结果(具体数据与运行环境相关):

循环次数


串行执行耗时/ms


并发执行/ms


1万


1


2


一百万


7


4


一亿


172


90


当数据不超过一百万时,并发执行速度会比串行执行快慢。那么,为什么并发执行的速度会比串行慢呢?这是因为 线程有创建和上下文切换的开销

2)如何减少上下文切换

减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程。

无锁并发编程: 多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据。

CAS算法: Java的Atomic包使用CAS算法来更新数据,而不需要加锁。

使用最少线程: 避免创建不需要的线程,如果任务很少 ,但是创建了很多的线程来处理,这样会造成大量线程都处于等待状态。

协程: 在单线程里实现多任务的调度,并在单线程里维护多个任务间的切换。

4、可靠性问题

可能会由一个线程导致JVM意外终止,其他的线程也无法执行。

六、底层原理(重点)

1、多线程内存模型

1)运行原理

当一个CPU需要读取主存时,它会将主存的部分读到CPU缓存中。它甚至可能将缓存中的部分内容读到它的内部寄存器中,然后在寄存器中执行操作。当CPU需要将结果写回到主存中去时,它会将内部寄存器的值刷新到缓存中,然后在某个时间点将值刷新回主存。

史上最全JAVA多线程文档 - 从入门到精通

多CPU: 一个现代计算机通常由两个或者多个CPU。其中一些CPU还有多核。从这一点可以看出,在一个有两个或者多个CPU的现代计算机上同时运行多个线程是可能的。每个CPU在某一时刻运行一个线程是没有问题的。这意味着,如果你的Java程序是多线程的,在你的Java程序中每个CPU上一个线程可能同时(并发)执行。

CPU寄存器: 每个CPU都包含一系列的寄存器,它们是CPU内内存的基础。CPU在寄存器上执行操作的速度远大于在主存上执行的速度。这是因为CPU访问寄存器的速度远大于主存。

高速缓存cache: 由于计算机的存储设备与处理器的运算速度之间有着几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。CPU访问缓存层的速度快于访问主存的速度,但通常比访问内部寄存器的速度还要慢一点。每个CPU可能有一个CPU缓存层,一些CPU还有多层缓存。在某一时刻,一个或者多个缓存行(cache lines)可能被读到缓存,一个或者多个缓存行可能再被刷新回主存。

内存: 一个计算机还包含一个主存。所有的CPU都可以访问主存。主存通常比CPU中的缓存大得多。

2)问题

(1)缓存一致性问题

史上最全JAVA多线程文档 - 从入门到精通

史上最全JAVA多线程文档 - 从入门到精通

Read(读取):从主内存读取数据

Load(载入):将主内存读取到的数据写入工作内存

Use(使用):从工作内存读取数据来计算

Assign(赋能):将计算好的值重新赋值到工作内存中

Store(存储):将工作内存数据写入主内存

Write(写入):将store过去的变量值赋值给主内存中的变量

Lock(锁定):将主内存变量加锁,标识为线程独占状态

Unlock(解锁):将主内存变量解锁,解锁后其他线程可以锁定该变量

由于两个线程的工作内存不一致,线程二改的工作内存,没有通知到主内存去通知其它工作内存,导致线程一的工作内存还是旧的内容,也就是缓存不一致问题。

(2)指令重排序问题

在不影响 单线程 程序执行结果的前提下,为了使得处理器内部的运算单元能尽量被充分利用,最大限度发挥机器性能,会对机器指令重排序优化。

重排序会遵循as-if-serial(不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。)与happens-before原则。

史上最全JAVA多线程文档 - 从入门到精通

正常顺序执行,输出的只有[0,1]和[1,0],但是由于计算机可能对机器指令重排序优化,所以执行的顺序发生了变化,导致最终可能输出的结果是 [0,0]、[0,1]、[1,0]、[1,1]

3)解决

概念:缓存一致性协议(MESI)

多个cpu从主内存读取同一个数据到各自的高速缓存,当其中某个cpu修改了缓存里的数据,该数据会马上同步回主内存,其它cpu通过总线嗅探机制可以感知到数据的变化从而将自己缓存里的数据失效。

史上最全JAVA多线程文档 - 从入门到精通

(1)valatile

valatile就是通过MESI实现的,所有valatile修饰的变量都会加入内存屏障(编译的时候可以看到有lock),(缓存的可见性原理)保证多个线程之间及时的可见性和有序性,但不保证原子性。

史上最全JAVA多线程文档 - 从入门到精通

再比如经典的高并发下的双重检测锁DCL指令重排问题

史上最全JAVA多线程文档 - 从入门到精通

史上最全JAVA多线程文档 - 从入门到精通

根据as-if-serial,在单线程内部可能会存在new DoubleLockSingleton()执行到init方法的时候,先执行putstatic,也就是先给instance变量赋值了,后再执行init方法。但是在高并发的情况下,如果按照这个顺序执行,对其他线程拿这个值的时候,是会有影响的,虽然这个概率极低。所以最终再instance变量前加一个volatile解决。

(2)缓存加锁

缓存锁的核心机制就是基于缓存一致性协议来实现的,一个处理器的缓存回写到内存会导致其他处理器的缓存失效。由于valatile只能保证可见性和有序性,无法保证原子性的问题,所以需要synchronize去保证原子性。

2、并发锁机制

1)锁状态

从jdk1.6开始为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。锁共有四种状态,级别从低到高分别是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。随着竞争情况锁状态逐渐升级、锁可以升级但不能降级。整体的锁状态升级流程如下:

史上最全JAVA多线程文档 - 从入门到精通

HotSpot作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入偏向锁。

史上最全JAVA多线程文档 - 从入门到精通

线程1检查对象头中的Mark Word中是否存储了线程1,如果没有则CAS操作将Mark Word中的线程ID替换为线程1。此时,锁偏向线程1,后面该线程进入同步块时不需要进行CAS操作,只需要简单的测试一下Mark Word中是否存储指向当前线程的偏向锁,如果成功表明该线程已经获得锁。如果失败,则再需要测试一下Mark Word中偏向锁标识是否设置为1(是否是偏向锁),如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将偏向锁指向当前线程

偏向锁的竞争结果:

根据持有偏向锁的线程是否存活

1.如果不活动,偏向锁撤销到无锁状态,再偏向到其他线程

2.如果线程仍然活着,则升级到轻量级锁

偏向锁、轻量级锁、重量级锁的优缺点:


优点


缺点


适用场景


偏向锁


加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。


如果线程间存在锁竞争,会带来 额外的锁撤销的消耗


适用于 只有一个线程访问 同步块场景。


轻量级锁


竞争的线程不会阻塞,提高了程序的响应速度。


如果始终得不到锁竞争的线程使用 自旋会消耗CPU


(CAS)



追求响应时间。同步块执行速度非常快。


重量级锁


线程竞争不使用自旋,不会消耗CPU。


线程阻塞 ,响应时间缓慢。


追求 吞吐量 (线程多时使用,直接放在队列)



同步块执行速度较长。


2)CAS(Campare And Swap):

例子:

底层实现逻辑: 先获取旧值与新值作比较,把旧的值与现在这个值作对比,如果一样,然后再把新值设置成新的值。

CAS会有两个问题可以思考:

1)原子性问题:在汇编语言层面还是有lock的,所以能保证原子性。

2)ABA问题:线程1准备用CAS将变量的值由A替换为B,在此之前,线程2将变量的值由A替换为C,又由C替换为A,然后线程1执行CAS时发现变量的值仍然为A,所以CAS成功。但实际上这时的现场已经和最初不同了,尽管CAS成功,但可能存在潜藏的问题。 通常可以忽略这个问题,但是如果一定要解决,可以用版本戳version来对记录或对象标记

3)并发优化

(1)锁优化

1、减少锁的持有时间

例如避免给整个方法加锁

1 public synchronized void syncMethod (){

2 othercode1 ();

3 mutextMethod ();

4 othercode2 ();

5 }

改进后

1 public void syncMethod2 (){

2 othercode1 ();

3 synchronized ( this ){

4 mutextMethod ();

5 }

6 othercode2 ();

7 }

2、减小锁的粒度

将大对象,拆成小对象,大大增加并行度,降低锁竞争. 如此一来偏向锁,轻量级锁成功率提高.

一个简单的例子就是jdk内置的ConcurrentHashMap与SynchronizedMap.

Collections.synchronizedMap

其本质是在读写map操作上都加了锁, 在高并发下性能一般.

ConcurrentHashMap

内部使用分区Segment来表示不同的部分, 每个分区其实就是一个小的hashtable. 各自有自己的锁.

只要多个修改发生在不同的分区, 他们就可以并发的进行. 把一个整体分成了16个Segment, 最高支持16个线程并发修改.

代码中运用了很多volatile声明共享变量, 第一时间获取修改的内容, 性能较好.

3、读写分离锁替代独占锁

顾名思义, 用ReadWriteLock将读写的锁分离开来, 尤其在读多写少的场合, 可以有效提升系统的并发能力.

  • 读-读不互斥:读读之间不阻塞。
  • 读-写互斥:读阻塞写,写也会阻塞读。
  • 写-写互斥:写写阻塞。

4、锁分离

在读写锁的思想上做进一步的延伸, 根据不同的功能拆分不同的锁, 进行有效的锁分离.

一个典型的示例便是LinkedBlockingQueue,在它内部, take和put操作本身是隔离的,

有若干个元素的时候, 一个在queue的头部操作, 一个在queue的尾部操作, 因此分别持有一把独立的锁.

/** Lock held by take, poll, etc */ 
private   final ReentrantLock takeLock  =    new    ReentrantLock  (); 
 
/** Wait queue for waiting takes */ 
private   final Condition notEmpty  =   takeLock.  newCondition  (); 
 
/** Lock held by put, offer, etc */ 
private   final ReentrantLock putLock  =    new    ReentrantLock  (); 
 
/** Wait queue for waiting puts */ 
private   final Condition notFull  =   putLock.  newCondition  (); 

5、锁粗化

通常情况下, 为了保证多线程间的有效并发, 会要求每个线程持有锁的时间尽量短,

即在使用完公共资源后, 应该立即释放锁. 只有这样, 等待在这个锁上的其他线程才能尽早的获得资源执行任务.

而凡事都有一个度, 如果对同一个锁不停的进行请求 同步和释放, 其本身也会消耗系统宝贵的资源, 反而不利于性能的优化

一个极端的例子如下, 在一个循环中不停的请求同一个锁.

for  (int i  =    0  ; i  <    1000  ; i  ++  ){ 
   synchronized  (lock){ 
 
   } 
} 
 
    // 优化后 
synchronized  (lock){ 
   for  (int i  =    0  ;i  <    1000  ; i  ++  ){ 
 
   } 
} 

锁粗化与减少锁的持有时间, 两者是截然相反的, 需要在实际应用中根据不同的场合权衡使用.

(2)ThreadLocal

1、描述

ThreadLocal一般称为线程本地变量,它是一种特殊的线程绑定机制,将变量与线程绑定在一起,为每一个线程维护一个独立的变量副本。通过ThreadLocal可以将对象的可见范围限制在同一个线程内。更直白点讲,ThreadLocal可以理解为将对象的作用范围限制在一个线程上下文中,使得变量的作用域为“线程级”。

2、运行原理

查看ThreadLocal的源码

可以看到内部有一个ThreadLocalMap,每次都会获取当前线程,在从当前线程中插入或拿取值,这样就保证了只能当前线程能插入和拿取自己的值,其他线程是访问不到的,另外他是通过Entry把对应的数据存储起来的。这样也就避免了传参问题。(脑补下,比如一个值好几个方法都要用到,普通的做法就是一个一个方法传递下去,但是使用ThreadLocal后,调用get方法就可以拿到参数值了,是不是很方便)

3、应用场景

  1. 最典型的是管理数据库的Connection, ThreadLocal能够实现当前线程的操作都是用同一个Connection,保证了事务!
  2. 避免一些参数传递, 比如Cookie和Session、上下文等

4、内存泄露

内存泄露主要出现在无法关闭的线程中, 例如web容器提供的并发线程池, 线程都是复用的.

由于ThreadLocalMap生命周期和线程生命周期一样长. 对于一些被强引用持有的ThreadLocal, 如定义为static.

如果在使用结束后, 没有手动释放ThreadLocal, 由于线程会被重复使用, 那么会出现之前的线程对象残留问题,

造成内存泄露, 甚至业务逻辑紊乱.

对于没有强引用持有的ThreadLocal, 如方法内变量, 是不是就万事大吉了呢? 答案是否定的.

虽然ThreadLocalMap会在get和set等操作里删除key 为 null的对象, 但是这个方法并不是100%会执行到.

看ThreadLocalMap源码即可发现, 只有调用了getEntryAfterMiss后才会执行清除操作,

如果后续线程没满足条件或者都没执行get set操作, 那么依然存在内存残留问题.

private   ThreadLocal.ThreadLocalMap.Entry  getEntry  (ThreadLocal key) {
    int i  =   key.threadLocalHashCode  &   (table.length  –    1  );
    ThreadLocal.ThreadLocalMap.Entry e  =   table[i];
    if   (e  !=    null    &&   e.  get  ()  ==   key)
        return   e;
    else
        // 并不是一定会执行
        return    getEntryAfterMiss  (key, i, e);
}

private   ThreadLocal.ThreadLocalMap.Entry  getEntryAfterMiss  (ThreadLocal key, int i, ThreadLocal.ThreadLocalMap.Entry e) {
    ThreadLocal.ThreadLocalMap.Entry[] tab  =   table;
    int len  =   tab.length;
    
    while   (e  !=    null  ) {
        ThreadLocal k  =   e.  get  ();
        if   (k  ==   key)
            return   e;
        // 删除key为null的value
        if   (k  ==    null  )
            expungeStaleEntry  (i);
        else
            i  =    nextIndex  (i, len);
        e  =   tab[i];
    }
    return    null  ;
} 

最佳实践

不管threadlocal是static还是非static的, 都要像加锁解锁一样, 每次用完后, 手动清理, 释放对象.

5、内存释放

  • 手动释放: 调用threadlocal.set(null)或者threadlocal.remove()即可
  • 自动释放: 关闭线程池, 线程结束后, 自动释放threadlocalmap.
public    class    StaticThreadLocalTest   {
    private    static   ThreadLocal tt  =    new    ThreadLocal  ();
    public    static    void    main  (String[] args) throws InterruptedException {
        ExecutorService service  =   Executors.  newFixedThreadPool  (  1  );
        for   (int i  =    0  ; i  <    3  ; i  ++  ) {
            service.  submit  (  new    Runnable  () {
                @Override
                public    void    run  () {
                    BigMemoryObject oo  =    new    BigMemoryObject  ();
                    tt.  set  (oo);
                    // 做些其他事情
                    // 释放方式一: 手动置null
                    // tt.set(null);
                    // 释放方式二: 手动remove
                    // tt.remove();
                }
            });
        }
        // 释放方式三: 关闭线程或者线程池
        // 直接new Thread().start()的场景, 会在run结束后自动销毁线程
        // service.shutdown();
        while   (  true  ) {
            Thread.  sleep  (  24    *    3600    *    1000  );
        }
    }
}
// 构建一个大内存对象, 便于观察内存波动.
class    BigMemoryObject  {
    List  <  Integer  >   list  =    new    ArrayList  <>  ();
    BigMemoryObject  () {
        for   (int i  =    0  ; i  <    10000000  ; i  ++  ) {
            list.  add  (i);
        }
    }
} 

(3)无锁

与锁相比, 使用CAS操作, 由于其非阻塞性, 因此不存在死锁问题, 同时线程之间的相互影响,

也远小于锁的方式. 使用无锁的方案, 可以减少锁竞争以及线程频繁调度带来的系统开销.

例如生产消费者模型中, 可以使用BlockingQueue来作为内存缓冲区, 但他是基于锁和阻塞实现的线程同步. 如果想要在高并发场合下获取更好的性能, 则可以使用基于CAS的ConcurrentLinkedQueue. 同理, 如果可以使用CAS方式实现整个生产消费者模型, 那么也将获得可观的性能提升, 如Disruptor框架.