书名:冲刺高薪Offer——Java并发编程进阶及面试指南
ISBN:978-7-115-65552-3
本书由人民邮电出版社发行数字版。版权所有,侵权必究。
您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。
我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。
如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。
编 著 梁建全
责任编辑 李永涛
人民邮电出版社出版发行 北京市丰台区成寿寺路11号
邮编 100164 电子邮件 315@ptpress.com.cn
网址 http://www.ptpress.com.cn
读者服务热线:(010)81055410
反盗版热线:(010)81055315
本书是一份旨在帮助Java求职者在面试中脱颖而出的重要指南。本书涵盖Java并发编程的多个关键主题,如并发原理和线程安全、并发关键字原理、并发锁和死锁、并发容器和工具、并发线程池以及并发设计与实战等。本书的特色在于将“大厂”“名企”的面试问题和实践经验相结合,不仅对面试问题和面试官心理进行深度剖析,还对面试问题解答和相关技术点进行详细介绍,这样更有利于读者全面理解相关知识点和技术,并能够在实际工作和面试中灵活应用。
通过学习本书,读者可以深入了解“大厂”“名企”的面试问题和实践经验。本书提供的面试问题解答和宝贵经验将有助于读者在实际工作中提升自己的能力,并在面试中表现更加出色,提高面试成功率,斩获高薪Offer(职位)。无论是对面试准备还是对技能提升而言,本书都是读者不可或缺的指南,能够帮助读者在职业生涯中取得更大的成功。
本书旨在帮助Java求职者在面试中冲刺高薪Offer并提升工作中的并发编程能力。本书不仅提供丰富的并发理论知识,还汇集“大厂”“名企”的面试问题和实践经验,助力Java求职者在面试中脱颖而出,并在工作中提升高并发场景的应对能力。
本书结合大量面试问题和实践经验,分主题提供常见面试问题的解答思路和详细答案。此外,通过深入解读相关知识点的概念、原理、实践经验和案例,以及扩展详解内容,便于Java求职者深入理解相关知识点并将其应用于面试场景和实际工作中。
除了深度剖析面试问题,本书还深度剖析面试官的心理和考查目的。通过了解面试官的期望和评判标准,Java求职者可以更好地准备面试,并给出准确、全面的回答,从而斩获高薪Offer。
本书以“大厂”“名企”的面试问题和实践经验为支撑,将理论知识与实践相结合,全面提升求职者应用Java并发编程技术的能力,从而在面试和工作中取得更大的成功。
本书分为6章,涵盖并发原理和线程安全、并发关键字原理、并发锁和死锁、并发容器和工具、并发线程池以及并发设计与实战等关键主题。
● 第1章:深入探讨并发原理和线程安全的核心概念,关注面试中常见的难点,如线程和线程安全、JMM与线程安全的关系、多线程中的上下文切换、AQS以及CAS实现机制和原理等。通过学习本章内容,读者不仅能够获得相关面试问题的正确答案,还可以深入了解与线程安全相关的技术原理和应用,提高面试成功率和工作实践的能力。
● 第2章:详细介绍并发关键字原理的相关内容,关注面试中常见的难点,如final关键字对并发编程的作用、synchronized的特性和原理,以及volatile的使用及原理等。通过学习本章内容,读者将获得相关面试问题的正确答案,并深入掌握相关的进阶技术内容,提高面试成功率和并发编程应用的水平。
● 第3章:详细介绍并发锁和死锁的相关内容,关注面试中常见的难点,如Java并发锁的使用和原理、多线程死锁的预防和解决等。通过学习本章内容,读者将获得相关面试问题的正确答案,掌握使用锁和解决死锁的技能,并深入了解锁的底层原理,提高面试成功率和并发实践的能力。
● 第4章:重点讨论并发容器和工具的使用及原理,关注面试中常见的难点,如JUC包、JUC容器的实现原理、并发队列、JUC同步工具的使用及实现原理,以及ThreadLocal。通过学习本章内容,读者将获得相关面试问题的正确答案,并深入了解这些并发容器和工具的使用和实现原理,提高面试成功率和并发编程的技能水平。
● 第5章:重点介绍并发线程池的使用及实现原理,关注面试中常见的难点,如线程池的设计思想和实现原理、Java线程池使用经验等。通过学习本章内容,读者将深入理解线程池设计思想和实现原理,熟悉相关面试问题及其回答要点,并学习“大厂”对线程池实际应用的经验,提高面试成功率和实际项目中的应用能力。
● 第6章:详细讨论并发设计与实战内容,关注面试中常见的难点,如并发编程中常用的线程操作、并发编程中的设计实践和经验等。通过学习本章内容,读者将学习并发编程的应用技能、复杂案例设计与实现,以及并发实践经验,提高面试成功率和实际工作中的应用能力。
为了进一步支持读者的学习,推荐读者关注作者的微信公众号——西二旗程序员。通过该公众号,读者将获取行业内新的技术动态、面试技巧和实战经验。作者会定期发布有关面试资料、架构设计和实际项目中的最佳实践等内容,帮助读者在职业道路上不断成长。
“西二旗程序员”
微信公众号二维码
感谢读者选择本书。希望通过学习本书和利用“西二旗程序员”微信公众号交流与学习,读者能够在Java求职面试中斩获高薪Offer,并在职业生涯中取得更大的成功。
梁建全
2024年6月
“谈谈你对线程和线程安全的理解”这个问题涉及的知识面较广,实际上,面试官是在通过这个问题考查求职者对线程及并发编程知识的掌握程度,具体考查点如下。
● 线程的创建:考查求职者对创建线程的不同方式以及它们之间的区别的了解程度,而创建线程的方式影响着程序的性能与复杂性。
● 线程生命周期:考查求职者是否了解线程的状态及状态的切换,这关系到对程序行为的控制和预测。
● 线程调度的策略:评估求职者对操作系统线程调度策略的理解程度,该策略直接关系到线程执行的有效性和效率。
● 并发编程:了解求职者是否知道为什么现代应用程序需要并发编程,以及是否能够识别并发带来的潜在问题。
● 并行与并发的区别:评估求职者对并行和并发概念的理解程度,以及他们在实际情况下对并行与并发的应用能力。
● 同步与阻塞的机制和关系:考查求职者是否能够正确实现线程间的同步,以及是否能够正确理解同步和阻塞的关系。
● 线程安全的实现:评估求职者在面对共享资源时,是否能够采取合适的措施来确保线程安全。
这些考查点对程序员来说具有重要意义,因为它们是构建高效、稳定、可扩展的多线程应用程序的基础。求职者应该做好充分的准备,确保自己能够清晰、准确地回答问题,展示自己的知识和技能。在回答相关问题时可以基于以下思路。
(1)Java创建和启动线程的方式有哪些?它们之间有什么区别?
在Java中,创建和启动线程的方式主要有4种,分别为继承Thread类,实现Runnable接口,使用Callable和Future接口,使用线程池。开发者需要根据具体场景选择具体的方式,简单任务通常只需要使用Runnable接口或Thread类,而复杂的并发程序可能会需要使用Callable、Future接口和线程池来提供更高级的并发管理功能。
(2)Java线程都有哪些状态?其状态是如何切换的?
Java 线程有6种状态,分别是新建(New)状态、可运行(Runnable)状态、阻塞(Blocked)状态、等待(Waiting)状态、超时等待(Timed Waiting)状态和终止(Terminated)状态。调用线程方法时会发生状态的切换,比如,新建线程在调用start()方法后会进入可运行状态;在调用wait()、join()或sleep()等方法后会进入等待状态;调用 notify()、notifyAll()或unpark()方法会返回到可运行状态等。
(3)Java线程使用到了哪些调度策略?
Java线程调度主要依赖于底层操作系统的线程调度机制和Java虚拟机(Java Virtual Machine,JVM)的实现,常见的线程调度策略包括“时间片轮转调度”“优先级调度”“抢占式调度”等。
(4)为什么使用并发编程?需注意哪些问题?
并发编程使得程序能够同时执行多个任务,这可以显著提高应用程序的性能和响应速率,特别是在多个CPU的环境下。它对于实现高效的资源利用和处理大量数据或承担高用户负载的系统至关重要。但是,在使用并发编程时,需要特别注意线程安全问题,确保共享资源的正确管理,避免出现死锁和数据不一致等问题。正确地管理线程生命周期和状态切换,以及合理地使用同步机制,这些对于开发可靠的并发程序至关重要。
(5)并发编程和并行编程有什么区别?
并发和并行是两个不同的概念。并发是指系统能够同时处理多个任务的能力,同时处理多个任务并不意味着这些任务同时执行。并行是指多个CPU或计算机同时执行多个任务或工作负载的能力。
(6)什么是线程同步和阻塞?它们有什么关系?
线程同步是指当多个线程同时访问和修改同一个资源时,确保每次只有一个线程能够执行相关操作,以维护数据的一致性和完整性,通常可以使用锁或其他同步机制实现。而阻塞则是指当线程尝试获取一个已经被其他线程持有的锁时,它将暂停执行,即进入阻塞状态,直到锁被释放。线程同步和阻塞描述了多线程操作中的不同方面,同步关注的是如何安全地访问共享资源,而阻塞关注的是线程在等待某些操作完成时的状态。
(7)什么是线程安全?如何确保线程安全?
线程安全是指多线程执行时,同一资源能够安全地被多个线程同时访问而不引发任何问题,如数据污染或不一致。确保线程安全的方法很多,包括同步代码块、使用ReentrantLock、使用不可变对象,以及使用并发集合,如 ConcurrentHashMap等。
为了让大家对线程和线程安全内容有更深入的掌握和理解,灵活应对面试细节,接下来我们对上述解答要点逐个进行详解。
在Java中,创建和启动线程的方式主要有4种,分别为继承Thread类,实现Runnable接口,使用Callable和Future接口,使用线程池。
下面我们详细介绍这4种方式及其区别。
(1)继承Thread类。
当一个类继承自Thread类时,可以通过重写run()方法来定义线程执行的任务,然后通过创建该类的实例并调用start()方法来启动线程。代码如下。
class MyThread extends Thread { public void run() { // 线程执行的任务 } } MyThread t = new MyThread(); t.start();
这种方式的优点是编码简单,能够直接使用;缺点是Java不支持多重继承,如果我们的类已经继承了另一个类,就不能使用这种方式创建线程。
(2)实现Runnable接口。
实现Runnable接口是创建线程的另一种方式。我们需要实现run()方法,然后将Runnable实例传递给Thread类的构造器,最后调用线程的start()方法。代码如下。
class MyRunnable implements Runnable { public void run() { // 线程执行的任务 } } Thread t = new Thread(new MyRunnable()); t.start();
这种方式的优点是更灵活,允许我们的类继承其他类。同时,它也鼓励采用组合而非继承的设计原则,这使得代码更加灵活和易于维护。它的缺点是编程较复杂,需要构造Thread对象。
(3)使用Callable和Future接口。
Callable和Future接口是一种更灵活的线程机制。Future接口有几个方法可以控制关联的Callable任务对象。FutureTask实现了Future接口,通过它的get()方法可以获取Callable任务对象的返回值。代码如下。
FutureTask<Integer> futureTask=new FutureTask<Integer>( (Callable<Integer>)()-> { // 返回执行结果 return 123; } ); new Thread (futureTask,"返回值的线程").start(); try{ // 使用get()来获取Callable任务对象的返回值 System.out .println("Callable任务对象的返回值:"+futureTask.get()); }catch(Exception e) { e.printStackTrace(); }
相比于实现Runnable接口方式,使用Callable和Future接口可以返回执行结果,也能抛出经过检查的异常。这种方式更加灵活,适用于复杂的并发任务。它的缺点是相对复杂,get()方法在等待计算完成时是阻塞的。如果计算被延迟或永久挂起,调用者可能会长时间阻塞。
(4)使用线程池。
通过Executors的静态工厂方法获得ExecutorService实例,然后调用该实例的execute(Runnable command)方法即可使用线程池创建线程。一旦Runnable任务传递到execute()方法,该方法便会在线程池中选择一个已有空闲线程来执行任务,如果线程池中没有空闲线程便会创建一个新的线程来执行任务。示例代码如下。
public class Test4 { public static void main(String[] args) { ExecutorService executorService=Executors.newCachedThreadPool(); for (int i = 0; i < 5; i++){ executorService.execute(new MyTask()); System.out.println("************* a"+i+"*************"); } executorService.shutdown( ); } }
class MyTask implements Runnable{ public void run( ){ System.out.println(Thread.currentThread().getName()+"线程被调用了。"); } }
使用线程池方式的优点是能够自动管理线程的创建、执行和销毁,避免了创建大量线程引起的性能问题(因为频繁地创建和销毁线程会消耗大量系统资源),还能够限制系统中并发执行线程的数量,避免了大量并发线程消耗系统所有资源,导致系统崩溃。它的缺点是代码更为复杂,需要进行更多的设计和考虑,比如线程池的大小选择、任务的提交与执行策略等。如果线程池使用不当或没有正确关闭,可能会导致资源泄漏。
(5)4种方式的区别。
上述4种创建和启动线程的方式都有其适用场景和优缺点。
● 继承Thread类:简单直接,适用于简单的线程任务,不需要返回值,也不抛出异常,但在某些情况下因为Java的单继承限制而不够灵活。
● 实现Runnable接口:更加灵活,分离了线程的创建和任务的执行,符合面向对象的设计原则,适用于多个线程执行相同任务的场景,特别是当需要访问当前对象的成员变量和方法时。
● 使用Callable和Future接口:比实现Runnable接口复杂一些,使用也更复杂,但是提供了更强大的功能,适用于需要返回执行结果的多线程任务,或者需要处理线程中的异常的场景。
● 使用线程池:重用线程,减少创建和销毁线程的开销,并提供了控制最大并发线程数、调度、执行、监视、回收等一整套线程管理解决方案。
综上所述,每种方式都有其用武之地,开发者需要根据具体场景选择适合的创建和启动线程的方式。简单任务通常只需要使用Runnable接口或Thread类,而复杂的并发程序可能会需要使用Callable、Future接口和线程池来提供更高级的并发管理功能。
Java线程在其生命周期中可以处于以下6种状态。
(1)新建(New)状态。
线程在被创建之后、调用start()方法之前的状态称为新建状态。在这个状态下,线程已经被分配了必要的资源,但还没有开始执行。
(2)可运行(Runnable)状态。
在线程调用了Thread.start()方法之后,它的状态被切换为可运行状态。在这个状态下,线程可能正在运行也可能没有运行,这取决于操作系统给线程分配执行时间的方式。可运行状态包括运行(Running)和就绪(Ready)两个状态,但在Java线程状态中,没有明确区分这两个状态,都归为“可运行状态”。
(3)阻塞(Blocked)状态。
当线程试图获取对象锁来进入同步块,但该锁被其他线程持有时,它就会进入阻塞状态。处于阻塞状态的线程会在获得锁之前一直等待。
(4)等待(Waiting)状态。
线程通过调用wait()、join()、park()等方法进入等待状态。处于等待状态的线程需要等待其他线程执行特定操作(例如通知、中断)才能返回到可运行状态。
(5)超时等待(Timed Waiting)状态。
超时等待状态是线程等待另一个线程执行一个(有时间限制的)操作的状态。比如,调用sleep(long)、wait(long)、join(long)等方法,线程会进入超时等待状态。在指定的时间后,线程将自动返回到可运行状态。
(6)终止(Terminated)状态。
当线程执行完毕,或者线程被中断时,线程会进入终止状态。在这个状态下,线程的任务已经完成,不能再次启动。
了解了线程状态后,我们继续了解线程状态的切换,这有助于我们更好地理解多线程程序的运行机制,以及掌握如何正确地控制线程的执行流程,如图1-1所示。
图1-1
在Java线程中,状态的切换通常是由线程的生命周期事件或对线程执行的操作引起的。下面是线程状态切换的常见路径。
(1)从新建状态到可运行状态。
当线程被创建后,它处于新建状态。调用线程对象的start()方法会启动新线程,并使线程进入可运行状态。
Thread t = new Thread(); // 线程处于新建状态 t.start(); // 线程进入可运行状态
(2)从可运行状态到阻塞状态。
当线程试图获取对象锁来进入同步块,但该锁被其他线程持有时,线程会从可运行状态切换到阻塞状态。
synchronized (obj) { // 如果其他线程已经持有obj的锁,当前线程将进入阻塞状态 }
(3)从阻塞状态返回到可运行状态。
当线程在阻塞状态下等待的锁变得可用时,线程会再次进入可运行状态。
(4)从可运行状态到等待状态/超时等待状态。
当线程调用wait()、join()、park()等方法时,它可以从可运行状态切换到等待状态。
Object.wait(); // 线程进入等待状态 Thread.join(); // 线程进入等待状态,直到对应的线程结束
当线程调用有时间限制的方法时,它会进入超时等待状态。
Thread.sleep(1000); // 线程进入超时等待状态,在指定时间后自动返回可运行状态 Object.wait(1000); // 线程进入超时等待状态,在指定时间后自动返回可运行状态
(5)从等待状态/超时等待状态返回到可运行状态。
线程从等待状态/超时等待状态返回到可运行状态通常是由于某个条件被满足,例如:
● 对于调用wait()方法的线程,某个线程调用了相同对象的notify()或notifyAll()方法;
● 对于调用join()方法的线程,线程执行完毕;
● 对于sleep(long)或wait(long)等调用的线程,指定的等待时间已经过去。
(6)从可运行状态到终止状态。
当线程的run()方法执行完毕时,线程将会进入终止状态。
public void run() { // 线程的工作代码 } // run()方法执行完毕,线程进入终止状态
调用interrupt()方法来请求中断线程也会使线程进入终止状态。
t.interrupt(); // 请求中断线程
以上是线程状态切换的常见路径,理解这些切换对于编写多线程程序是非常重要的。我们在编写多线程程序时,需要考虑线程同步、互斥锁、等待/通知机制等关键问题。
Java线程调度主要依赖于底层操作系统的线程调度机制和JVM的实现。因此,具体的线程调度策略可能会根据操作系统和JVM的不同而有所差异。常见的线程调度策略包括“时间片轮转调度”“优先级调度”“抢占式调度”。
(1)时间片轮转调度。
在时间片轮转调度策略中,每个线程被分配一个固定长度的时间段,这个时间段称为“时间片”。所有可运行的线程轮流使用CPU(Central Processing Unit,中央处理器)资源,每个线程在其分配的时间片内运行。如果线程在其时间片用完之前完成了任务,它将释放CPU;如果线程的时间片用完了,该线程会被暂停,操作系统会将CPU分配给下一个线程。时间片转轮调度尝试给每个线程分配公平的CPU时间。
(2)优先级调度。
在优先级调度策略中,每个线程都有一个优先级。当多个线程可运行时,具有最高优先级的线程将首先获得CPU。Java提供了1~10这10个不同的优先级,通过Thread类的setPriority(int)方法设置线程的优先级。然而,优先级的实际效果高度依赖操作系统的调度策略,某些操作系统可能会忽略这些优先级或只是粗略地实现。
(3)抢占式调度。
在实践中,大多数现代操作系统使用的是一种叫作“抢占式多任务处理”的调度算法,它结合了时间片轮转和优先级两种方式。操作系统会根据线程的优先级来分配CPU资源,但同时也会在必要时通过时间片轮转来确保资源的公平分配。
在日常开发中,我们可以使用一些线程控制方法,比如yield()和sleep()等。这些方法调用并不是直接绑定到特定的线程调度策略(如时间片轮转调度或优先级调度),它们与线程调度策略的关系更多地取决于底层操作系统如何实现线程调度,以及JVM如何在该操作系统上工作。下面我们对yield()和sleep()两个方法进行详细解释。
(1)yield()方法。
yield()方法是一种提示性的方法,它提示调度器当前线程愿意让出其当前的CPU使用权。但是,它只给出一个提示,而调度器可能会忽略这个提示。如果调度器接受这个提示,那么当前线程会从运行状态转移到就绪状态,从而允许具有相同优先级的其他线程获得执行机会。不过,调度器可能会立即重新调度这个刚刚让出CPU的线程。yield()方法的行为在很大程度上依赖于具体的操作系统和JVM实现。
(2)sleep()方法。
sleep()方法使当前线程暂停执行指定的时间(以毫秒为单位),使线程进入超时等待状态,但该线程不会释放任何锁资源。调用sleep()意味着线程至少需要等待指定的时间后才能再次进入可运行状态。一旦指定的时间过去,线程就会进入可运行状态,等待调度器的调度。sleep()的使用不依赖于线程调度策略,但是线程从超时等待状态“醒来”并变为可运行状态后,在何时开始运行将取决于操作系统的线程调度策略。
虽然Java允许开发者设置线程的优先级,但这些优先级的实际效果和表现依赖于JVM和操作系统的具体实现。建议开发者不要仅依赖于线程优先级来实现关键的功能逻辑,因为代码在不同的平台上可能会有不同的行为表现。可以使用同步控制、锁、并发容器、并发集合等技术,提供更具确定性的方式来编写并发程序,从而降低代码在不同平台上行为不一致的风险。
总的来说,线程控制方法的作用与操作系统的线程调度策略有关,但它们本身并不指定使用哪种线程调度策略。它们的行为将受到当前操作系统的线程调度算法和JVM实现的影响。由于JVM也运行在宿主操作系统之上,因此它也依赖于操作系统的线程调度策略。
并发编程是允许多个任务同时进行而不是顺序执行的一种编程技术。它涉及操作系统、编程语言、软件开发等多方面内容。并发编程的作用是让程序能够更有效地使用计算资源,特别是在多个CPU的系统上,它也用于处理同时发生的多个任务或请求。
假设我们正在为一家金融公司开发一个实时股票价格分析系统。该系统需要实时跟踪数百只股票的价格变动,并且对价格变化进行快速分析,从而为交易员提供买卖股票的决策支持。该系统的关键要求是低延迟,因为股市价格波动迅速,高延迟可能导致巨大的财务损失。
如果该系统串行处理每只股票的价格变动和分析,就会导致巨大的延迟,因为这样的系统会在处理完一只股票的所有价格变动和分析后才能开始处理下一只的。在高峰时段,价格变动的速度可能会超过系统处理的速度,导致数据堆积和过时。
如果采用并发编程,可以为每只或每组股票分配一个独立的处理线程或者使用事件驱动模型来处理股票的价格变动。每个线程可以独立地跟踪和分析一只或一组股票的价格变动,从而减少数据处理的延迟。使用并发队列来管理价格变动相关的事件,可以确保每只股票的价格变动都能够尽快地被处理。
使用并发编程有以下几个优点。
● 性能提升:并发编程可以显著提升应用程序在多个CPU上的性能,通过并行处理可以同时执行多个操作,相比串行处理能更快完成任务集合。
● 资源利用最大化:程序在并发执行时,可以更充分地利用CPU和其他资源,因为当一部分任务等待I/O(Input/Output,输入输出)操作或被阻塞时,其他任务可以继续进行计算。
● 吞吐量增加:对于服务端应用,使用并发编程能够同时处理多个客户端请求,从而增加应用程序的吞吐量。
● 响应性增强:在用户界面程序中,即使部分任务很耗时,通过并发编程也可以保持界面的响应性,因为耗时操作可以在独立的线程或过程中执行。
当然,除了优点以外,使用并发编程也存在以下几个缺点。
● 复杂性增加:并发代码通常比顺序执行的代码更复杂,需要更多的设计和调试时间,且难度更大。
● 存在同步问题:线程或进程间的同步(如互斥锁、信号量等)是并发编程中的一大挑战,不当的同步可能导致死锁。
● 调试困难:并发程序的调试通常比单线程程序的更加困难,因为问题可能只在特定的并发条件下才会发生,不可重现的问题更是常见。
● 性能开销较大:并发编程需要额外管理线程或进程的开销,如上下文切换和同步机制等,这些可能会抵消一些性能上的优势。
● 设计和测试的工作量较大:并发程序的设计和测试工作量通常要大于非并发程序的,因为需要考虑多种可能的执行顺序和交互情况。
使用并发编程可以提高程序的性能和响应率,充分利用计算机的多核处理能力。并发编程可以让程序在同时处理多个任务或请求时更加高效。
然而,并发编程面临着一些问题和挑战,使用并发编程需要注意以下几点。
● 同步机制:当多个线程同时访问和修改共享数据时,可能会导致数据不一致的问题。需要使用锁、原子性操作等机制来保证数据的一致性。
● 死锁:当多个线程持有资源并且互相等待其他线程释放资源时,可能会导致死锁。需要使用合适的资源分配和竞争避免策略避免死锁的发生。
● 上下文切换:当多个线程在同一个CPU上进行切换时,会消耗一定的时间和资源。需要合理控制线程的数量,避免过多的线程导致过多的上下文切换,影响性能。
● 线程间通信:线程或进程之间的通信通常需要特殊的同步机制,如信号量、锁、事件等。正确实现这些同步机制是确保数据一致性和程序正确性的关键。
● 并发安全性:需要保证程序在并发环境下的正确性和安全性。避免数据竞争、死锁和其他并发相关的问题。
● 性能优化:并发编程可能会带来一些性能问题,如线程间的争用、同步开销等。需要针对具体场景进行性能优化,提高并发程序的效率和吞吐量。
综上所述,虽然并发编程可以带来很多好处,但也需要注意解决并发相关的问题和挑战。合理的并发设计和编程技巧可以帮助我们充分发挥并发编程的优势,并确保程序的正确性和性能。我们需要深入理解并发模型,熟悉同步机制,并注意程序可能遭遇的并发相关问题和挑战。尽管存在问题和挑战,但在多个CPU日益普及的今天,适当地使用并发编程依然可以带来很多显著的好处。
并发(Concurrency)和并行(Parallelism)这两个概念在多任务处理领域经常被对比讨论。尽管这两个术语在日常用语中有时被交替使用,但在计算机科学中,它们有着明确且不同的含义。
(1)并发。
并发是指系统能够同时处理多个任务的能力。并发的重点在于任务的处理过程,而不是执行。并发涉及同时处理多个任务的能力,但这不一定意味着这些任务实际上是在同一时刻执行的。在单核CPU上,一个CPU可以通过任务间的快速切换,给用户一种多个任务同时执行的错觉。并发更多关注的是结构上的分解,即如何有效地组织程序以同时处理多个任务。
比如,操作系统中存在多任务处理,即使在只有一个CPU内核的计算机上,仍然可以同时浏览网页、播放音乐和编写文档。操作系统通过将CPU资源切片并分配给各个程序,使之能够并发运行,从宏观上看,这些程序似乎是在同时执行的,但是在CPU上它们实际上是串行执行的。
(2)并行。
并行是指多个CPU或计算机同时执行多个任务或工作负载的能力。并行的重点是性能,它通过同时执行多个操作,减少完成工作的总时间。并行需要使用多个CPU或计算机,其中每个CPU或计算机执行任务的不同部分。
例如,我们在使用多个CPU进行科学计算时,其中一个任务是计算一个大型数据集中所有元素的总和,那么这个任务可以被分割成更小的部分,每个部分分配给一个CPU,多个CPU同时计算。随后,所有CPU的计算结果被汇总以获得最终总和,这种方式显著减少了完成计算所需的总时间。
(3)并发编程和并行编程的区别。
了解并发和并行的概念后,我们应该知道,并发编程和并行编程也是多线程编程的两个概念,它们在Java中都有应用,但各自的侧重点和使用场景有所不同。
● 并发编程是关于如何利用有限的CPU资源高效地管理和调度多个任务,这些任务可能不会真正同时执行,但通过任务间的快速切换给用户以同时执行的错觉。
● 并行编程是关于如何将任务分配到多个CPU上,以便真正同时执行,从而提高程序的运行效率。
并发和并行都是现代计算中提高效率的关键概念,它们使得程序能够更加高效地利用资源。在多个CPU的环境下,并发和并行经常一起使用,以实现最大的效率和性能。在Java中,这两个概念也是交织在一起的,一个并发程序可以通过在多个CPU上并行执行多个线程来提高性能。然而,并发编程侧重于线程之间的协调和同步,而并行编程则侧重于线程的同时执行和性能提升。
在并发编程中,我们经常会遇到线程同步、异步、阻塞和非阻塞等概念,尤其是在涉及线程之间的协作和资源共享时。其实同步和异步是指线程执行方式,而阻塞和非阻塞是指线程执行状态。我们来详细介绍这些重要概念。
(1)线程同步(Thread Synchronization)。
线程同步是一种机制,使用它能够确保两个或多个并发线程不会同时执行特定的程序片段。这通常用于防止多个线程访问共享资源(如数据结构、文件或外部设备等),以避免数据不一致或状态冲突的问题。线程同步可以通过以下多种机制来实现。
● 互斥锁(Mutex Lock):确保同一时间只有一个线程可以进入临界区。
● 信号量:允许多个线程在资源数量有限的情况下进行同步。
● 监视器(Monitor):封装了对象的锁定和条件变量,简化了同步过程。
● 死锁避免算法:确保系统不会进入一个无法分配资源的状态。
在Java中,线程同步通常是使用synchronized关键字、volatile关键字、锁技术以及原子类等方法来实现的。当一个线程进入一个同步方法或同步代码块时,它会自动获取锁;当它离开时,锁会被释放,此时其他线程可以获取锁并进入该同步方法或同步代码块。
(2)线程阻塞(Thread Blocking)。
线程阻塞指的是线程因为某些条件尚未满足而暂停执行,并且该线程会从CPU的执行队列中移除,直到某个特定的事件发生。在阻塞期间,线程不会消耗任何CPU时间,因此CPU可以执行其他任务。
线程被阻塞的原因如下。
● I/O操作:当线程等待来自I/O设备的数据时,通常会发生阻塞。
● 同步锁:当线程试图获取一个已经被其他线程持有的锁时,通常会发生阻塞。
● 其他阻塞操作:例如等待某个事件发生或尝试执行一个已经满载的同步阻塞队列操作。
在Java中,导致线程阻塞的方法通常有使用Object类的wait()方法、Thread类的sleep()和join()方法、Lock接口的lock()方法以及Condition接口的await()方法等。
(3)线程同步和阻塞的关系。
线程同步和阻塞描述了多线程操作中的不同方面,同步关注的是如何安全地访问共享资源,而阻塞关注的是线程在等待某些操作完成时的状态。同步操作可能会导致线程阻塞,但阻塞本身并不一定是同步操作的结果,例如线程在等待I/O操作完成时也会发生阻塞,这与同步没有直接关系。
存在同步阻塞,也存在同步非阻塞,当然还存在异步阻塞和异步非阻塞。它们的作用都是保证多线程环境中程序的正确性和一致性,但如果不恰当地使用它们,可能会导致性能问题,如死锁或饥饿等。因此,在设计多线程程序时,需要仔细考虑线程之间的同步和阻塞策略。
线程安全是指多线程执行时,同一资源能够安全地被多个线程同时访问而不引发任何问题,如数据污染或不一致。一个线程安全的程序能够正确地处理并发请求,不论线程执行的顺序如何。
在实际开发中,线程安全非常重要,因为多个线程经常会同时访问共享数据或资源,如果没有采取适当的保护措施,就会导致数据不一致、错误或丢失等问题。
为了保证线程安全通常需要结合使用多种策略和技术,以下是一些保证Java线程安全的常见方案。
(1)同步代码块。
使用synchronized关键字可以确保同时只有一个线程可以执行某个方法或代码块。这是最直接的同步手段,可以保护共享资源的独占访问。
(2)使用ReentrantLock。
java.util.concurrent.locks.ReentrantLock提供了一种比synchronized关键字更灵活的锁定机制。该机制可以尝试非阻塞地获取锁,也可以中断等待锁的线程,还可以实现公平锁等。
(3)使用原子类。
java.util.concurrent.atomic包提供了一系列的原子类,例如AtomicInteger和AtomicReference等,这些类内部使用了高效的机制来确保单个变量操作的原子性。
(4)使用volatile关键字。
volatile关键字可以确保变量的读写操作都直接作用于主内存,保证了新值对其他线程的可见性。它适用于一个变量的写入不依赖于当前值的情况。
(5)使用ThreadLocal类。
ThreadLocal类可以创建线程局部变量,确保每个线程都有自己的变量副本,因此使用它不会出现线程安全问题。
(6)使用并发集合类。
java.util.concurrent包提供了一系列的并发集合类,例如ConcurrentHashMap、ConcurrentLinkedQueue等,这些类内部已经处理了并发控制。
(7)使用并发工具类。
java.util.concurrent包还提供了许多并发工具类,例如Semaphore、CyclicBarrier、CountDownLatch和Exchanger等,可以用于复杂的线程同步。
(8)使用不可变对象。
不可变对象的状态无法改变,自然就不会出现线程安全的问题。使用String、BigDecimal和BigInteger等类可以创建不可变对象。
在并发编程中,选择合适的方法确保线程安全非常重要,需要根据具体情况进行权衡。例如,synchronized使用简单但可能会导致性能问题;而原子类适合计数器或状态标志;不可变对象完全避免了并发问题,但不适合所有场景。此外,我们在设计程序时应该遵循并发设计模式,比如单例模式、生产者-消费者模式、读写锁模式等。因此,设计线程安全的系统既是一种技术挑战,也是对设计能力的一个考验。
JMM是理解线程安全的核心概念,它定义了线程和主内存之间的抽象关系,以及线程如何通过内存进行通信。掌握JMM的相关知识对于编写线程安全的代码至关重要。面试官提出“介绍JMM与线程安全的关系”这个问题,旨在考查求职者对Java多线程编程的理解程度,以及在并发控制领域的知识水平。
面试官提出这个问题背后的目的是检测求职者是否理解在并发编程中保证操作可见性、原子性和有序性的重要性,这些都是JMM正确运行的关键保证。作为求职者,我们在面试时应该重点讲述JMM的主要组成部分,如它的工作原则、内存屏障、happens-before原则等。同时,应该强调自己如何使用同步机制来保证线程安全,举例说明如何在实际编程中遵循JMM来避免数据竞争等问题。这样的答案能够向面试官展示深厚的理论基础和丰富的实践经验。
我们可以针对面试官的考查目的对这个问题进行拆解,将其拆分成多个问题点再进行解答,解答要点如下。
(1)什么是JMM?它有哪些特征和作用?
JMM(Java内存模型)是一个抽象的概念,旨在定义程序中各种变量的访问规范,以及线程与主内存之间的交互方式。它的特征包括可见性、原子性和有序性;作用是解决并发编程中的可见性问题和原子性问题,确保程序运行的正确性和性能。
(2)JMM和Java内存结构有什么区别?
JMM与Java内存结构(堆、栈、方法区等)不同,JMM关注的是变量之间的相互作用和线程如何通过内存进行通信,而Java内存结构关注的则是数据存储、分配和管理的物理层面。
(3)JMM内存是如何交互的?都有哪些操作?
在JMM中,线程与主内存之间的交互主要通过读取、写入、锁定等操作进行。每个线程都有自己的工作内存,它会先从主内存复制变量到工作内存中进行读写操作,再将更新后的变量写回主内存。
(4)什么是happens-before原则?它有什么作用?
happens-before原则是JMM中的一个核心概念,它用于确定内存操作的顺序关系,确保程序的有序性。如果一个操作与另一个操作之间存在happens-before关系,那么第一个操作的结果对第二个操作来说是可见的。
(5)什么是指令重排序和内存屏障?
指令重排序是编译器或处理器为了优化程序性能而采用的一种技术,能够改变程序指令的执行顺序。内存屏障是一种机制,用于防止指令重排序,保证特定操作的执行顺序,从而维护happens-before原则。
(6)如何保证程序的可见性、原子性和有序性?
保证程序的可见性、原子性和有序性通常通过同步机制来实现,如使用volatile关键字可以保证变量修改的可见性,使用synchronized关键字或锁机制(如ReentrantLock)可以保证操作的原子性和有序性。此外,利用final关键字也可以在某些场景下保证程序的可见性和有序性。
为了让大家对JMM与线程安全内容有更深入的掌握和理解,灵活应对面试细节,接下来我们对上述解答要点逐个进行详解。
JMM是Java Memory Model(Java内存模型)的缩写,与JVM内存结构不同,它是一个抽象的概念,描述的是一组与多线程相关的规范,需要各个JVM的实现来遵守,开发者可以利用这些规范,更方便地开发多线程程序。在使用JMM的情况下,即便同一个程序在不同的虚拟机上运行,得到的程序结果也是一致的。
JMM定义了程序中的操作如何在多线程环境下交互,以及线程如何通过内存进行通信。当有多个线程操作内存中的共享数据时,JMM定义了线程与主内存之间的抽象关系以及同步这些操作的方式,确保线程安全性、内存的可见性、原子性和有序性,以下是JMM规范定义的主要内容。
(1)变量的存储。
JMM描述了程序中的变量如何存储在内存中以及如何通过线程访问这些变量。所有变量存放在主内存中,而每个线程有自己的工作内存,工作内存用于存放该线程使用到的主内存变量副本。线程对变量的操作都在工作内存中进行。线程不能直接读写主内存中的变量。每个线程的工作内存都是独立的,线程只能先在工作内存中操作变量,然后将变量同步到主内存,如图1-2所示。
图1-2
(2)操作的原子性。
JMM规定哪些操作是原子性的,即不可中断的。例如,对于非long或double类型的变量的读写操作通常是原子性的,但这些操作的复合操作(如递增操作)不是原子性的。
(3)变量的可见性。
JMM规定何时以及如何将更新后的变量值从工作内存同步到主内存,以及从主内存更新到各个线程的工作内存,确保一个线程对共享变量的修改对其他线程可见。
(4)变量修改的有序性。
JMM规定在不影响单线程程序执行结果的前提下,允许编译器和处理器对操作顺序进行重排序,但必须遵守特定的规则(比如使用volatile关键字、final关键字和synchronized块/方法)以保证在多线程环境中程序的有序性和正确性。
(5)锁的语义。
JMM定义了锁和同步的语义,确保获取锁的线程能看到由先前持有同一锁(并已释放该锁)的其他线程所作的修改。
整个JMM实际上是围绕着以下3个特征建立起来的,这3个特征可谓是整个Java并发编程的基础。
(1)原子性(Atomicity)。
原子性是指一个或一系列操作是不可中断的,即使是在多线程同时执行的情况下,一个操作(或对某个变量的操作)要么完全执行,要么完全不执行,不会停留在中间某个步骤。JMM只能保证基本的原子性,如果要保证一个代码块的原子性,可以通过synchronized或java.util.concurrent包中的原子类(如AtomicInteger)来保证。
(2)可见性(Visibility)。
可见性是指如果一个线程修改了共享变量的值,其他线程能够立刻得知这个修改。Java提供了volatile关键字来保证变量的可见性,用volatile修饰一个共享变量可以保证对这个变量的读写都是直接操作主内存,而不是线程的工作内存。
(3)有序性(Ordering)。
有序性是指程序按照代码的先后顺序执行。在JMM中,由于编译器优化和处理器优化,可能会出现指令重排序,打乱原来的代码执行顺序。为了解决这个问题,JMM提出了happens-before原则来保证程序的有序性。通过synchronized或volatile也可以保证多线程之间操作的有序性。
总之,JMM规范屏蔽掉了各种硬件和操作系统的内存访问差异与实现细节,这些细节对于Java开发者而言是透明的,理解JMM提供的规则和保障对编写正确的并发程序至关重要。通过遵循JMM规范,开发者可以编写出既安全又高效的多线程Java程序,并且让Java程序在不同平台上都能达到一致的内存访问效果,这就是JMM的意义。
JMM和Java内存结构很容易让人混淆,但它们是Java中两个截然不同的概念,关注的领域和目的各不相同,下面我们进行详细介绍。
(1)JMM。
JMM是一个抽象的概念,它定义了JVM在多线程环境中如何处理内存的读写操作,以及线程如何通过内存进行交互。JMM关注的是变量之间的相互作用和线程如何通过内存进行通信。它提供了一套规则,确保在多核处理器的环境下,程序执行的正确性得以保障。
JMM的主要功能和目标如下。
● 定义共享变量的读写如何在线程间传递。
● 确保多线程环境下,程序执行的一致性和安全性。
● 为开发者提供一种机制,使得开发者在编写并发程序时能够考虑到硬件和编译器的内存访问优化。
(2)Java内存结构。
Java内存结构,又被称为JVM运行时数据区,是JVM在执行Java程序时用来存储数据和管理内存的实际架构。它定义了JVM在执行Java程序时如何使用内存,包括各种运行时数据区的划分,如方法区(Method Area)、堆(Heap)空间、栈(Stack)空间、程序计数器(Program Counter)和本地方法栈(Native Method Stack)。
Java内存结构的主要功能和目标如下。
● 定义方法区来存储类信息、常量、静态变量等。
● 定义堆空间来存储Java对象实例。
● 定义栈空间来存放局部变量、操作数栈、方法出入口等。
● 定义程序计数器来为每个线程保留当前执行的指令地址。
● 定义本地方法栈来支持本地方法执行。
(3)JMM和Java内存结构的区别。
从本质上讲,JMM是关于线程并发执行时内存操作的规范,它解决的问题是如何在多线程环境中安全有效地进行内存交互。而Java内存结构解决的是程序数据存储的物理或逻辑结构问题,主要用于指导JVM应该如何管理内存。
简而言之,JMM是关于线程如何交互和内存访问规则的高层规范,而Java内存结构是关于JVM如何存储数据和管理内存的实际架构。
在JMM中,所有的变量都存储在主内存中,每个线程有自己的工作内存。线程的工作内存中保存了该线程使用到的变量,它们是从主内存复制的副本。线程对变量的所有操作(比如读取、赋值等)都必须在工作内存中进行,而不能直接在主内存中进行,并且每个线程不能访问其他线程的工作内存。为了实现JMM这个特性,JMM定义了8种内存操作,具体如下。
● lock:锁定操作,作用于主内存的变量,它标记一个变量开始处于独占状态。
● unlock:解锁操作,作用于主内存的变量,它标记一个变量结束独占状态。
● read:读取操作,作用于主内存的变量,它将一个变量的值从主内存传输到线程的工作内存中,以便随后的载入操作使用。
● load:载入操作,作用于工作内存的变量,它在读取操作之后执行,将读取操作得到的值放入工作内存的主内存变量副本中。
● use:使用操作,作用于工作内存的变量,它将工作内存中的一个变量的值传递给线程使用。
● assign:赋值操作,作用于工作内存的变量,线程通过它将一个值赋给工作内存中的变量。
● store:存储操作,作用于工作内存的变量,它将工作内存中的一个变量的值传递到主内存中,以便随后的写入操作使用。
● write:写入操作,作用于主内存的变量,它在存储操作之后执行,将存储操作得到的值放入主内存的变量中。
上述这些内存操作必须按照特定的顺序执行,这个顺序由happens-before原则来定义,具体交互过程如图1-3所示。
图1-3
JMM还规定了执行上述8种内存操作时必须满足的规则,具体如下。
● 如果要把一个变量从主内存中复制到工作内存,就需要按顺序执行读取和载入操作;如果要把一个变量从工作内存同步回主内存,就需要按顺序执行存储和写入操作。但JMM只要求上述操作必须按顺序执行,而没有要求必须连续执行。
● 不允许读取和载入、存储和写入操作之一单独出现。
● 不允许一个线程丢弃它最近的赋值操作,即变量在工作内存中改变了之后必须同步到主内存中。
● 不允许一个线程无原因(没有发生过任何赋值操作)地把数据从工作内存同步到主内存中。
● 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(载入或赋值)的变量,即对一个变量实施使用和存储操作之前,必须先执行赋值和载入操作。
● 对于一个变量,在同一时刻只允许一个线程对其进行锁定操作,但锁定操作可以被同一个线程重复执行多次,多次执行锁定操作后,只有执行相同次数的解锁操作,变量才会被解锁。锁定和解锁操作必须成对出现。
● 如果对一个变量执行锁定操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行载入或赋值操作初始化变量的值。
● 如果一个变量事先没有被锁定操作锁定,则不允许对它执行解锁操作;也不允许对一个被其他线程锁定的变量执行解锁操作。
● 对一个变量执行解锁操作之前,必须把此变量同步到主内存中。
JMM通过上述操作,结合happens-before原则,定义了线程通过主内存交互的方式,同步变量到工作内存的方式,以及工作内存与主内存之间的关系,等等。这些原则确保了在多线程环境中,共享变量的更新能够被其他线程看到,从而使得线程间的通信变得可靠和高效。这些操作基本上构成了线程间通过共享内存进行通信的基础,保证了Java程序在多线程环境中能有正确的并发行为。
happens-before(先行发生)是JMM中的一个核心概念,它定义了一组规则,用来确定内存操作之间的顺序。1.2.3小节讲到的JMM内存操作必须要满足一定的规则,happens-before就是定义这些规则的一个等效判断原则。简而言之,如果操作A happens-before操作B,则可以保证操作A产生的结果对操作B是可见的,即操作B不会看到操作A的执行结果之前的状态。
happens-before的作用是解决并发环境下的内存可见性和有序性问题,确保多线程程序的正确性。如果两个操作满足happens-before原则,那么不需要进行同步操作,JVM能够保证操作的有序性,但此时不能随意进行指令重排序;否则,JVM无法保证操作的有序性,就能进行指令重排序。
happens-before原则定义的规则具体如下。
(1)程序代码顺序规则。
在同一个线程中,按照程序代码顺序,前面的操作发生在后面的操作之前。例如在同一线程内,如果我们先写入一个变量,再读取同一个变量,那么写入操作happens-before读取操作。
int x=0;//写入操作 int y=x;//读取操作,这里能看到x=0
注意,程序代码顺序要考虑分支、循环等结构,因此该顺序确切来讲应该是程序控制流顺序。
(2)监视器锁规则。
解锁发生在加锁之前,且必须针对同一个锁。例如synchronized块,解锁happens-before加锁。
synchronized(lock) { sharedVar = 1; // 在锁内的写入操作 }//lock解锁happens-before加锁 synchronized(lock) { int r = sharedVar; // 在另一个锁内的读取操作,这里能看到sharedVar=1 }
(3)volatile变量规则。
对一个volatile变量的写入操作发生在读取操作之前,示例如下。
volatile int flag = 0; // 线程A flag = 1; // 写入操作 // 线程B int f = flag; // 读取操作,这里能看到flag=1
(4)线程启动规则。
Thread对象的start()方法发生在线程的每一个后续操作之前,示例如下。
Thread t = new Thread(new Runnable() { public void run() { int readX = x; // 线程中的任何操作,能看到start()之前的写入操作 } }); x = 10; // 主线程写入操作 t.start(); // start() happens-before子线程中的所有操作
(5)线程终止规则。
线程中的所有操作,例如读取、写入和加锁等,都发生在这个线程终止之前,也就是说,当我们观察到一个线程终止时,就可以确认该线程的所有操作都已经完成了。例如,如果线程A在终止之前修改了一个共享变量,当我们通过join()方法等待线程A终止或者使用isAlive()方法检查到线程A已经不再活动时,就可以确信线程A中的所有操作都已经执行完毕,包括对共享变量的修改。示例如下。
Thread threadA = new Thread(() -> { // 这里是线程 A 的操作 someSharedVariable = 123; // 对共享变量的写入操作 }); threadA.start(); // 启动线程 A threadA.join(); // 等待线程 A 终止 // 当 threadA.join() 结束后 // 可以确信threadA对someSharedVariable 的写入操作已经完成 assert someSharedVariable == 123; // 这里可以安全地检查共享变量的值
在上述代码中,使用assert表达式检查someSharedVariable是否为123是安全的,因为threadA.join()保证了所有线程A中的操作在主线程观察到线程A终止之前都已经完成。
(6)线程中断规则。
对一个线程调用interrupt()方法,实际上是设置了该线程的中断状态,主线程的interrupt()调用发生在子线程检测到中断之前,示例如下。
Thread t = new Thread(new Runnable() { public void run() { while (!Thread.currentThread().isInterrupted()) { // 业务处理逻辑 } // 能看到中断状态 } }); t.start(); t.interrupt(); // 主线程的interrupt()调用发生在子线程检测到中断之前
(7)对象终结规则。
一个对象的初始化完成,即构造函数的执行完成,发生在finalize()方法之前,示例如下。
public class ExampleObject { private int x; public ExampleObject() { x = 10; // 构造函数的写操作 } protected void finalize() { int readX = x; // 在finalize()中,可以看到构造函数的写操作结果 } }
(8)传递性。
如果A操作发生在B操作之前,且B操作发生在C操作之前,则A操作发生在C操作之前,示例如下。
volatile int flag = 0; int a = 0; // 线程A a = 1; // A操作 flag = 1; // B操作 // 线程B if (flag == 1) { // C操作 int readA = a; // 这里可以保证readA = 1,因为A happens-before B happens-before C }
上述这些规则,为Java程序员在多线程环境中编写线程安全的代码提供了一个清晰的框架。通过理解和运用这些规则,可以避免数据竞争和内存一致性错误。
总之,happens-before是理解和正确使用JMM的关键,通过happens-before定义的规则我们可以更好地理解多线程间的内存操作如何互相影响。
指令重排序是编译器和处理器为了优化程序性能而采用的一种技术。这种技术能够改变程序指令执行的顺序,但保证在单线程环境中最终结果的一致性。
根据发生的层面,指令重排序可以分为3种,分别为编译器优化重排序、指令级并行重排序、内存系统重排序。重排序流程如图1-4所示,后面两种为处理器级别。
图1-4
● 编译器优化重排序:在编译时,编译器可能会改变语句的顺序来提高执行效率,同时保证程序的行为不变。
● 指令级并行重排序:现代处理器采用指令级并行(Instruction Level Parallelism,ILP)技术将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
● 内存系统重排序:其为伪重排序,也就是说只是看起来像在乱序执行而已。对于现代处理器来说,在CPU和主内存之间都存在高速缓存,高速缓存的作用主要为减少CPU和主内存的交互。在CPU进行读取操作时,如果缓存中没有相关数据则从主内存取;而对于写入操作,先将数据写入缓存中,最后一次性写入主内存,这样做的作用是减少跟主内存交互时CPU的短暂卡顿,从而提升性能,但是延时写入可能会导致数据不一致问题。
理解指令重排序有助于开发者预见和避免潜在的并发问题。编译器和处理器并非在任何场景下都会进行指令重排序的优化,而是会遵循一定的原则,as-if-serial语义就是重排序都需要遵循的原则。as-if-serial语义规定在单线程中,只要不改变程序的最终执行结果,为了提升性能就可以改变指令执行的顺序。但是,在多线程程序中,指令重排序可能会导致一些问题。例如,一个线程对共享变量的修改可能由于重排序而未按预期顺序对其他线程可见,从而导致数据竞争和不一致的问题。为了解决这些问题,在编译器方面使用volatile关键字可以禁止指令重排序,但在硬件方面,需要使用JMM定义的内存屏障(Memory Barrier)来实现禁止指令重排序。
内存屏障也称为内存栅栏,是一种同步机制,可以确保指令执行的顺序满足特定的一致性要求。它在编译器优化和处理器执行指令时发挥作用,防止这些环节中的指令重排序引发问题。内存屏障可确保在屏障之前的所有操作完成后才开始执行屏障之后的操作。内存屏障在硬件层面和JVM层面都有实现,具体如下。
(1)硬件层内存屏障。
硬件层内存屏障有加载屏障(Load Barrier)和存储屏障(Store Barrier)两种特定类型,这两种屏障主要用于编译器和处理器级别,避免由指令重排序导致的多线程程序中的数据不一致问题。
● 加载屏障:确保所有对内存的读取操作在屏障指令之后的读取操作执行前完成。这意味着,加载屏障后的读取操作必须等待所有先前的读取操作完成,确保得到的数据是最新的。加载屏障主要用于防止指令重排序中的读取操作被提前执行。
i = a; LoadBarriers; //其他操作
上述代码中,LoadBarriers可以确保程序在执行其他操作之前,从主内存中读取a的变量值并且刷新到缓存中。
● 存储屏障:确保所有的写入操作在屏障指令之后的写入操作执行前完成。这确保了屏障之前的所有写入操作对接下来的写入操作可见。存储屏障用于防止写入操作重排序,确保按照程序的预期顺序执行写入操作。
a = 1; b = 2; c = 3; StoreBarriers; // 其他操作
上述代码中,StoreBarriers可以确保在执行其他操作之前,写入缓存中的a、b、c这3个变量值同步到主内存中,并且其他线程可以观察到变量的变化。
在多处理器系统中,这两种屏障特别重要,因为它们帮助维护跨不同处理器的数据的一致性。例如,如果一个处理器更新了共享数据,通过使用适当的屏障,可以确保这些更新对在其他上运行的线程立即可见。
在实际应用中,这两种屏障经常与其他类型的内存屏障一起使用,如全屏障(Full Barrier),它同时包括加载屏障和存储屏障的功能,确保所有的读写操作都在屏障之后的操作之前完成。
(2)JVM内存屏障。
在JVM中,内存屏障是一种底层同步机制,用于实现JMM规定的内存可见性和有序性保证。这些屏障不是由Java语言直接提供的,而是由JVM实现的,并且通常在编译器生成的机器代码中插入,确保正确读写操作,以及锁的正确获取和释放。
JVM内存屏障大致可以分为以下4种。
● LoadLoad屏障:放在两个读取操作之间,确保第一个读取操作的结果在第二个读取操作开始之前必须被获取。
int i = a; LoadLoad; int j = b;
上述代码中,LoadLoad可以确保int i=a读取操作在int j=b读取操作之前,禁止它们进行重排序。
● StoreStore屏障:放在两个写入操作之间,确保第一个写入操作的结果在第二个写入操作开始之前必须被刷新到主内存。
a = 1; StoreStore; b = 10;
上述代码中,StoreStore可以确保a=1写入操作的结果在b=10写入操作开始之前被刷新到主内存,禁止它们进行重排序。
● LoadStore屏障:放在读取操作之后、写入操作之前,确保读取操作的结果对接下来的写入操作可见。
int i = a; LoadStore; b = 10;
上述代码中,LoadStore可以确保int i=a读操作在int b=10写操作之前,禁止它们进行重排序。
● StoreLoad屏障:最昂贵的屏障,确保之前的所有写入操作完成之后,才执行后续的读取操作。
a = 1; StoreLoad; int i = b;
上述代码中,StoreLoad可以确保a=1写入操作在int i =b读取操作之前,禁止它们进行重排序。
这些JVM内存屏障在使用volatile关键字、synchronized关键字和java.util.concurrent包中的锁时都会被用到。当定义一个volatile变量时,JVM会在写操作之后插入一个StoreStore屏障,以确保这次写操作对其他线程立即可见;同时,可能还会插入一个StoreLoad屏障来保证写操作之后的读操作不会读取到旧值。虽然我们在编写代码时不需要直接应用这些内存屏障,因为它们由JVM底层自动处理,但是理解它们的存在和作用对于编写并发和多线程程序是很关键的,特别是在调试和性能优化时。
可见性、原子性和有序性是并发的三大特征,也是JMM的特征,为了保证并发程序的正确性,我们需要考虑这3个关键特征,下面我们详细介绍它们面临的问题及其解决方案。
(1)可见性。
可见性指的是当一个线程修改了共享变量的值后,其他线程能够立即知道这个修改。导致可见性问题的原因主要有以下几点。
● 缓存一致性问题:在多处理器系统中,每个处理器通常都有自己的本地缓存(L1缓存、L2缓存等),本地缓存用以加速处理器对内存的访问。当多个处理器的缓存中都存储了同一个内存变量的副本时,一个处理器对副本的修改可能不会立即反映到其他处理器的缓存中。
● 编译器优化:为了提高程序性能,编译器可能会重排指令执行顺序,这可能导致其他线程在不适当的时候看到共享变量的数据。
● JMM的延迟特性:即使采用不带缓存的系统,JMM本身也可能导致其他处理器或线程看到过时的数据。
为解决可见性问题,我们可以采用以下常见方案。
● 使用volatile关键字:当一个变量使用volatile修饰后,所有对这个变量的写入操作都将立即同步到主内存中,同时所有对这个变量的读取操作都将直接从主内存中读取,从而保证了变量的可见性。
● 使用synchronized关键字:当一个变量处于synchronized同步代码块中时,程序执行进入块时将清空工作内存中的变量值,在需要时会从主内存中重新读取;退出块时将工作内存中的变量值刷新回主内存,从而保证了变量的可见性。
● 使用final关键字:对于使用final修饰的字段,一旦被初始化后其值就不能修改,其他线程总是能够看到final字段的初始化值。
(2)原子性。
原子性是指一个或一系列操作是不可中断的,即使是在多线程同时执行的情况下,一个操作(或对某个变量的操作)要么完全执行,要么完全不执行,不会停留在中间某个步骤。导致原子性问题的原因主要有以下几点。
● 线程上下文切换:在多线程环境中,线程可以在任意时间被操作系统挂起并切换到另一个线程。如果这种切换发生在一个复合操作(如递增操作)的中间,那么其他线程可能会看到一个不一致的状态。
● 非原子性操作:计算机的指令集通常只保证基本读写操作的原子性。对于复合操作,例如“检查再运行”(check-then-act)或“读取-修改-写入”(read-modify-write),不通过特定的同步机制是无法保证操作的原子性的。
为解决原子性问题,我们可以采用以下常见方案。
● 使用synchronized块:synchronized块(或方法)可以确保在同一时间只有一个线程执行该代码块,保证了操作的原子性。
● 使用锁:比如ReentrantLock,锁可以提供比synchronized更复杂和灵活的操作来实现同步。
● 使用原子类:比如AtomicInteger,Java的java.util.concurrent.atomic包提供了一系列原子类,通过CAS(Compare And Swap,比较并交换)操作保证了原子性。
(3)有序性。
有序性指的是程序按照代码的先后顺序执行,从而保证程序的正确性。导致有序性问题的原因主要有以下几点。
● 编译器优化:为了提高程序执行效率,编译器在生成机器代码时可能会调整指令的顺序。这种重排序对单线程程序来说通常是安全的,但在多线程程序中可能会导致严重问题。
● 处理器优化:现代处理器为了更高效地利用处理器资源和执行单元,会对输入的指令流进行动态重排序。这种指令重排序可能会导致指令执行顺序与程序代码中的顺序不一致。
● 内存系统:不同类型的内存访问有不同的访问速度,处理器可能会通过重排序内存访问指令来优化性能,这可能导致指令执行的顺序和程序中的顺序不一致。
为解决有序性问题,我们可以采用以下常见方案。
● 使用volatile:除了保证可见性外,volatile还可以防止指令重排序。编译器和处理器在遇到volatile变量时,会在读写操作前后添加内存屏障,防止其前后的操作重排序。
● 使用synchronized关键字和锁:这些同步措施会限制多个线程之间操作的执行顺序,它们可以保证锁定同一监视器的同步代码块只能串行执行。
● 遵循happens-before原则:JMM通过happens-before原则保证程序的有序性。例如,每个volatile写入操作之前的所有操作都将在volatile写入操作和后续的volatile读取操作之间对其他线程可见。
总之,为了解决上述问题,JMM定义了一系列happens-before原则来保障多线程之间的内存可见性、原子性和有序性。开发者也需要根据实际情况选用synchronized、volatile、final、锁等机制来确保并发环境下的正确性。最后,简单总结几种常见解决方案的区别,如表1-1所示。
表1-1
特性 |
volatile |
final |
synchronized |
锁 |
原子类 |
---|---|---|---|---|---|
可见性 |
可保障 |
可保障 |
可保障 |
可保障 |
可保障 |
原子性 |
无法保障 |
无法保障 |
可保障 |
可保障 |
可保障 |
有序性 |
一定程度保障 |
一定程度保障 |
可保障 |
可保障 |
无法保障 |
上下文切换是多线程环境下不可避免的,它发生在操作系统中断当前执行的线程并启动另一个线程的过程中,此过程涉及保存和恢复线程的状态信息,对系统资源和程序执行效率有重大影响。
面试官提出“谈谈多线程中的上下文切换”这个问题,背后的目的是探究求职者对于多线程程序性能挑战的认识,以及他们是否能够在设计和优化并发程序时考虑减少上下文切换带来的开销。面试官通过这个问题评估求职者在开发大型复杂系统时资源管理和线程调度的综合能力。
求职者应当考虑上下文切换可能导致的性能问题以及如何减小这些问题的影响,可以提到一些具体的策略,比如优化锁的使用以减少锁竞争,使用最小化线程数量,以及利用线程池来避免频繁创建和销毁线程,等等。通过这些策略,求职者可以展示自己对多线程编程细节的深入了解和解决实际问题的能力。我们可以将面试官的考查目的进行拆解,拆成多个问题点进行解答,解答逻辑如下。
(1)什么是上下文切换?上下文切换会带来哪些问题?
上下文切换是指计算机操作系统为了执行多任务或多线程,保存一个进程或线程的状态(上下文)以便稍后可以恢复到这个状态并继续执行的过程。上下文切换会带来性能开销,因为它涉及CPU寄存器内容和程序计数器的保存与恢复,这会消耗计算资源。频繁进行上下文切换可能导致CPU花费大量时间在任务切换而非任务执行上,从而引起系统性能下降。
(2)什么是进程上下文切换?引起进程上下文切换的原因有哪些?
进程上下文切换指的是操作系统挂起一个进程的执行并启动另一个进程的执行过程中所涉及的活动,将CPU资源从一个进程分配给另一个进程的过程。这种切换通常由当前执行的进程被中断(例如,等待I/O操作完成、系统资源调配、时间片用尽),操作系统决定切换到另一个进程继续执行引起。
(3)什么是线程上下文切换?与进程上下文切换有何区别?
线程上下文切换是指在多线程操作系统中,CPU从一个线程切换到另一个线程执行的过程。与进程上下文切换相比,线程上下文切换通常代价较小,因为同一进程内的线程共享进程资源,切换线程不需要切换内存空间和I/O环境。但线程上下文切换仍然涉及寄存器状态和栈空间的变更等。
(4)如何查看线程上下文切换信息?
在不同的操作系统中,有不同的工具和命令可以查看线程上下文切换信息。例如,在Linux系统中,可以通过pidstat命令来监控上下文切换的次数和频率。在Windows系统中,可以使用性能监视器(Performance Monitor)来查看线程上下文切换的相关数据。
(5)如何减少线程上下文切换的次数?
减少线程上下文切换的次数可以通过多种策略实现,比如减少线程竞争、合理设置线程数、使用线程池、避免使用不必要的锁、优化任务调度策略、优化代码逻辑等,在使用时需要根据业务实际需求进行选择。
我们在回答上述问题时,应当展现出对线程和进程的上下文切换原理的清晰理解,并能提出实际的解决方案来优化应用性能,这样更易获得面试官的认可。
为了让大家对多线程中的上下文切换内容有更深入的掌握和理解,灵活应对面试细节,下面我们对上述解答要点逐个进行详解。
上下文切换是操作系统中的一个过程,涉及保存一个进程或线程的状态(上下文)以便稍后恢复执行这个进程或线程的能力。当操作系统决定把CPU从一个进程(或线程)转移到另一个进程(或线程)上时,就会发生一次上下文切换。
上下文切换通常是计算密集型的,需要占用一定的CPU时间。每秒可能会发生几十甚至上百次的切换,每次切换都需要纳秒级的时间,所以上下文切换对系统来说意味着会消耗大量的CPU时间。Linux 相比于其他操作系统有很多的优点,其中有一项就是,它的上下文切换和模式切换的时间消耗非常少。
在上下文切换过程中,操作系统通常需要执行以下操作。
● 保存上下文信息:保存当前进程或线程的状态到其PCB(Process Control Block,进程控制块)或线程的内存结构中。状态信息包括程序计数器、寄存器值、内存状态等。
● 恢复上下文信息:当再次执行原进程或另一个进程时,操作系统从其PCB中恢复保存的状态信息,以便进程可以继续执行。
上下文切换通常发生在以下几种情况下。
● 多任务处理:在多任务操作系统中,为了公平地分配CPU时间或响应高优先级的任务,操作系统会在不同进程间进行切换。
● 等待I/O操作:当一个进程或线程进行I/O操作(如读写文件、进行网络通信等)时,它通常需要等待操作完成。在此期间,CPU可以切换到其他任务执行,以提高执行效率。
● 同步操作:进行同步操作(如等待互斥锁)而被阻塞的线程会导致操作系统切换到其他线程执行。
上下文切换是必要的,因为它支撑操作系统的多任务特性,使得用户可以同时运行多个进程,而每个进程似乎都独占了CPU。
上下文切换并非没有代价,它会产生开销,因为它涉及CPU状态的保存与恢复,以及相关系统资源的管理,所以频繁的上下文切换会影响系统的整体性能。因此,高性能系统的设计往往会尽量减少不必要的上下文切换,以提高执行效率。
(1)进程上下文。
进程上下文是指操作系统中某个进程完全运行所需要的状态信息。当操作系统的调度器从一个进程切换到另一个进程时,它需要保存当前进程的上下文,并加载下一个进程的上下文。进程上下文主要包括以下部分。
● CPU寄存器:包括程序计数器(它指向要执行的下一条指令)以及栈指针(Stack Pointer,SP,它指向进程栈中的当前位置)。此外,还包括累加器、索引寄存器和状态寄存器等。
● 程序状态字(Program Status Word,PSW):存储了进程的状态信息,比如条件代码、CPU的模式以及中断使能/禁用状态等。
● CPU内核栈:内核模式下使用的栈,通常用于存储内核过程或中断服务例程中的局部变量和返回地址。
● PCB:包含操作系统用于管理进程的各种信息,比如进程状态(运行、就绪、等待等)、进程ID、进程优先级、CPU时间、内存管理信息(如页表或段表),以及其他资源的追踪等。
● 内存管理信息:包括虚拟地址空间的状态,如页表或段表,它们记录了虚拟地址到物理地址的映射。
● 打开文件和I/O状态:进程打开的文件描述符、网络连接状态、缓冲区信息等都是进程上下文的一部分。
● 进程账户信息:如CPU时间、实际用户ID和有效用户ID等。
进程上下文确保了进程能够在被中断后恢复执行,即像没有被中断一样继续执行其任务。操作系统通过在进程间切换上下文来实现多任务处理。
(2)进程上下文切换。
在现代操作系统中,进程上下文切换是一项基础而关键的功能,它的高效实现对于提高系统整体性能至关重要。
进程上下文切换是指操作系统挂起一个进程的执行并启动另一个进程的执行过程中所涉及的活动,将CPU资源从一个进程分配给另一个进程的过程。这种切换通常发生在多任务操作系统中。从用户角度看,计算机能并行执行多个程序,这恰恰是操作系统快速进行进程上下文切换产生的效果。
在进程上下文切换的过程中,操作系统需要先存储当前进程的上下文状态信息,再加载下一个进程的上下文状态信息,然后执行此进程,如图1-5所示。
图1-5
进程上下文切换的详细过程如下。
①存储当前进程上下文状态信息。
在切换到新进程之前,系统需要存储当前正在运行进程的上下文状态信息,包括程序计数器、CPU寄存器的内容、系统调用状态、内核堆栈等信息。这些信息通常被存储在PCB中。
②加载下一个进程上下文状态信息。
系统随后加载下一个进程的上下文状态信息,这个进程可能是新选择的要执行的进程,或者是从等待状态被唤醒的进程。CPU寄存器会加载下一个进程的相关值,程序计数器中的指令地址也会被更新为下一个进程要执行的下一条指令地址。
③资源重新分配。
在上下文切换过程中,操作系统还需要管理并更新其他系统资源的状态,比如虚拟内存、I/O状态等。
(3)引起进程上下文切换的原因。
引起进程上下文切换的原因有以下几个方面。
● 时间片用尽:大多数操作系统通过时间片来分配进程的执行时间。当一个进程的时间片用尽时,操作系统会进行进程上下文切换,执行其他进程。
● I/O请求:如果一个进程发出I/O请求,那么在I/O操作完成前,它不需要CPU。在这种情况下,操作系统会进行进程上下文切换,执行其他进程。
● 高优先级进程:如果有高优先级进程需要执行,操作系统可能会打断当前进程的执行,先执行高优先级进程。
● 中断处理:硬件或软件中断可能导致当前进程被暂停,以便操作系统响应和处理中断。
虽然进程上下文切换是多任务操作系统的基础特性,但是它也有性能代价。过多的上下文切换会增加CPU的工作负载,降低处理效率。这个性能代价在系统设计和优化时通常需要被考虑。
线程上下文切换是指在多线程操作系统中,CPU从一个线程切换到另一个线程执行的过程。与进程上下文切换相似,线程上下文切换涉及保存当前线程的状态(这里的“状态”指的是线程特有的信息,如线程的程序计数器、寄存器集、栈指针)并恢复另一个线程的状态的操作,以便后者可以继续执行。
线程上下文切换通常比进程上下文切换要轻量,原因在于同一进程内的线程共享相同的进程资源。因此,切换线程不需要切换内存空间和I/O环境,这降低了切换的开销。但是,线程上下文切换仍然需要处理以下内容。
● CPU寄存器集:执行线程中计算的核心部分,包括程序计数器、栈指针、条件码以及通用目的寄存器等。
● 线程栈:每个线程有自己的栈,它用于存储局部变量、函数调用返回地址等。线程切换时,需要更新栈指针以反映新线程的栈状态。
● 线程特定数据(Thread-Specific Data,TSD):线程存储的其运行过程中的特有数据(如错误代码等)。
触发线程上下文切换的原因通常有以下几个。
● 多线程调度:操作系统根据线程的优先级和策略,决定哪个线程应该使用CPU。
● 阻塞操作:如果线程执行了阻塞操作(如等待I/O、获取无法立即获得的锁),操作系统会切换到另一个线程,以充分利用CPU资源。
● 时间片用尽:线程获得的CPU资源是有限的。当时间片用尽,操作系统会进行上下文切换,让另一个线程运行。
● 高优先级线程就绪:当一个高优先级线程从阻塞状态切换为就绪状态时,操作系统可能会打断当前线程,切换到高优先级线程运行。
在Java中,通过使用多线程并在其中执行一些阻塞操作或使用sleep()让线程睡眠,都会触发线程上下文切换。我们来看一个Java示例,示例代码如下。
public class ThreadSwitchExample { public static void main(String[] args) { // 创建并启动一个计算线程 Thread computationThread = new Thread(() -> { long sum = 0; for(long i = 0; i < 1000000L; i++) { sum += i; // 每计算一定次数,线程休眠一小段时间,增加上下文切换的可能性 if(i%1000 == 0) { try { Thread.sleep(1); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 重新设置中断状态 System.out.println("Computation thread was interru pted."); return; } } } System.out.println("Computation finished: " + sum); }); // 创建并启动一个等待用户输入的线程 Thread inputThread = new Thread(() -> { try { System.out.println("Waiting for user input:"); int read = System.in.read(); // 线程执行阻塞操作,等待用户输入 System.out.println("You entered: " + (char) read); } catch (IOException e) { System.out.println("An error occurred while reading input."); } }); // 启动线程 computationThread.start(); inputThread.start(); // 等待两个线程完成 try { computationThread.join(); inputThread.join(); } catch (InterruptedException e) { System.out.println("Main thread was interrupted."); } System.out.println("Main thread finished."); } }
在上述代码中,main()方法主线程启动了两个线程:一个是computationThread线程(用于进行计算),另一个是inputThread线程(用于等待用户输入)。两个线程的逻辑差异将会导致操作系统进行线程上下文切换。computationThread在计算过程中会间歇性休眠,休眠是通过sleep(1)方法实现的。这种休眠会使得操作系统有机会将CPU资源分配给其他的线程,从而引发线程上下文切换。inputThread使用System.in.read()方法执行阻塞操作直到接收到输入。在该线程等待用户输入期间,操作系统可能会将CPU资源分配给其他线程。
注意,虽然上述示例可能会引发线程上下文切换,但实际发生切换的频率和时机取决于许多因素,包括操作系统的调度策略、系统负载、线程的优先级等。
综上所述,线程共享相同的地址空间和资源,而进程拥有独立的地址空间和资源,因此线程上下文切换是同一进程内不同线程之间的切换,涉及较少的状态变更,成本较低。而进程上下文切换是不同进程之间的切换,涉及全面的地址空间和资源的变更,成本较高。在设计高性能应用程序时,通常会考虑使用多线程而不是多进程,以减少上下文切换的开销。
要查看Java程序中线程上下文切换的信息,需要使用操作系统级别的工具和JVM工具,或通过编程方式实现,说明如下。
(1)Linux系统工具。
pidstat是一个监视全部或特定进程和系统的统计数据的工具。可以先找到Java进程的ID,然后使用pidstat监视它,命令如下。
pidstat -w -p <PID> 1
在上述命令中,<PID>是Java进程的ID;1表示每1s收集一次数据;-w 选项表示显示线程上下文切换的信息。
perf是一个强大的性能分析工具,可以用它来分析上下文切换等事件。
perf stat -e context-switches,cpu-migrations -p <PID> sleep 10
上述命令可以测量指定进程在10s内的上下文切换次数与CPU迁移次数。
(2)Windows系统工具。
perfmon(Performance Monitor)是一款Windows自带的性能监视工具,提供了图表化的系统性能实时监视器、性能日志和警报管理。通过添加性能计数器(Performance Counter)可以实现对CPU、内存、网络、磁盘、进程等多类对象的上百个指标的监控,其中包括线程上下文切换。按Win+R组合键打开“运行”对话框,然后在“打开”文本框中输入“perfmon”后按Enter键,即可打开性能监视器,如图1-6所示。
图1-6
在图1-6中,选择左侧“监视工具”→“性能监视器”,会弹出右侧菜单,可以添加性能计数器进行监控。在Windows中也可以使用Process Explorer工具,它是Sysinternals套件中的一个工具,可以显示每个进程的详细信息,其中包括上下文切换次数。
(3)JVM工具。
VisualVM是一个图形界面工具,可以监控Java应用程序的CPU、内存使用情况,以及线程信息。虽然VisualVM主要用于性能分析和内存分析,但它可以帮助用户理解线程的行为,从而间接推断上下文切换的情况。
JConsole工具是一个JMX(Java Management Extensions,Java管理扩展)控制台,可以连接到运行中的JVM并监控其资源消耗,包括线程使用情况。
(4)编程方式。
在Java中,可以使用ManagementFactory.getThreadMXBean().getThreadInfo(threadId)方法来获取线程的信息,包括线程的状态。虽然这不直接提供上下文切换次数,但可以通过分析线程状态的变化来推测上下文切换次数。
注意,上下文切换是由操作系统管理的,因此大多数JVM工具提供的是间接的信息。直接的上下文切换次数和原因主要通过操作系统级别的工具获取。确保以合适的权限运行这些工具,以便收集所需的数据。
在多线程编程中,频繁触发线程上下文切换会带来很多问题,主要有以下几方面。
● CPU资源浪费:线程上下文切换涉及存储和加载线程状态,这是开销较大的操作,会消耗CPU资源。
● 缓存效率降低:线程切换可能导致CPU缓存失效,增加内存访问延迟,从而降低处理速度。
● 系统吞吐量下降:多线程的调度和上下文切换可能占用较多时间,减少CPU执行实际任务的时间,导致系统整体的吞吐量下降。
● 响应时间变慢:对于需要快速响应的任务,频繁的线程上下文切换可能导致处理延迟,影响用户体验。
简而言之,频繁的线程上下文切换会导致程序性能下降,这主要是CPU资源浪费、缓存效率降低和系统吞吐量下降所致的。因此,要充分发挥多线程编程的优势、提高系统的性能,就要减少线程上下文切换的次数,尤其是在多线程、高负载的情况下。下面是一些减少上下文切换次数的策略。
(1)减少线程竞争。
● 应用细粒度的锁。例如使用锁分离技术,让锁只保护核心部分,而不是整个对象或方法。
● 利用无锁编程技术(如使用原子性操作、读写锁等)。无锁数据结构可以避免锁的资源开销。
● 使用并发控制机制(如乐观锁和悲观锁)来控制数据的并发访问。
(2)合理设置线程数。
● 线程数应与系统CPU的数量相适应。一个常见的策略是设置线程数为CPU数的某个倍数,具体倍数取决于任务是计算密集型还是I/O密集型。
● 对于I/O密集型任务,线程数可以多于CPU数,因为线程会因等待I/O操作而阻塞。
● 对于计算密集型任务,保持线程数接近CPU数可以减少切换,进而充分利用CPU资源。
(3)使用线程池。
● 线程池通过重用一组固定数量的线程来执行任务,避免频繁创建和销毁线程的开销。
● 线程池可以根据队列中的任务自动调整线程的数量,提高效率和响应能力。
(4)避免使用不必要的锁。
● 检查代码以确认锁的必要性,有时候可以通过重新设计来避免锁的使用。
● 使用更高性能的锁机制,比如在某些情况下可重入锁(ReentrantLock)的性能可能比synchronized的性能更好。
● 减小锁的粒度,例如使用并发集合而不是在标准集合上加锁。
(5)优化任务调度策略。
● 使用优先级队列来管理任务,确保高优先级任务首先执行。
● 尽可能让线程在同一个CPU上运行(充分发挥CPU亲和性),以利用局部性原理和缓存效率。
● 利用任务亲和性,让相关联(可能共享相同数据)的任务在同一线程中执行,以减少切换和同步。
(6)优化代码逻辑。
● 精简同步块:尽量保持同步块的执行时间短,避免在同步块内进行耗时操作,防止因过度同步导致不必要的线程等待和切换。
● 减少阻塞调用:尽量避免使用sleep()、wait()等会造成线程阻塞的方法,可以考虑使用一些非阻塞方法进行替代。
● 避免创建不必要的线程:每个新增线程都增加了潜在的上下文切换成本。如果任务可以并发执行但不一定需要完全并行,则可以考虑使用线程池或ForkJoinPool来重用线程。
● 避免死锁、活锁等问题:这些问题会导致线程无效地等待并增加上下文切换次数。
● 使用局部变量:相比于共享变量,局部变量可以减少线程之间的数据共享,从而减少锁的需求。局部变量存储在线程自己的栈中,不会被其他线程访问,避免了同步问题。
采用上述策略可以显著减少线程上下文切换的次数,但是它们需要根据应用的具体需求和环境来调整。在实际操作中可能需要进行详细的性能分析来确定最佳的策略组合。
AQS是Java并发编程中的一个关键框架,为构建锁和同步器提供了基础。它使用一个int型volatile变量来表示同步状态,并通过内置的FIFO(First In First Out,先进先出)队列来管理线程的阻塞和唤醒。面试官提出“谈谈你对AQS的理解”这个问题背后的目的通常是评估求职者对Java并发编程中的核心同步组件的理解和应用能力。
求职者应对这个问题的策略如下。
● 基本介绍:解释AQS是什么,强调它是构建锁和其他同步器的一个框架,并且是许多Java并发工具的基础。
● 工作原理:详细描述AQS的工作原理,包括其使用一个int型volatile变量维护同步状态,以及通过内置的FIFO队列来管理线程的阻塞与唤醒。
● 同步组件的关系:讨论基于AQS实现的同步组件,解释它们是如何利用AQS的同步状态管理和线程排队机制来提供同步功能的。
● 自定义同步器:如果有经验,可以讲述自己是如何利用AQS实现自定义同步器的,从而表明自己对AQS的深入理解和实际应用能力。
● 实际示例:可能的话,提供一些实际编码经验,比如在项目中如何使用AQS提供的同步器,或者如何通过AQS解决特定的并发问题。
通过这样的回答,不仅能够展示出求职者对AQS基础知识的掌握程度,还能够证明求职者有能力深入理解并发机制,并在实际工作中应用这些知识。通过上述回答策略,我们可以将面试官提出的问题进行拆解,对应的子问题和回答思路如下。
(1)什么是AQS?它有什么作用?
AQS是AbstractQueuedSynchronizer的缩写,是Java并发包中的一个关键抽象类,用于构建锁或其他同步器。AQS利用一个int型volatile变量来表示同步状态,并且提供了一套方法来管理同步状态,以及一个基于FIFO队列的框架来管理那些等待获取同步状态的线程。AQS是实现ReentrantLock、Semaphore、ReentrantReadWriteLock等多种同步器的基础。
(2)AQS支持哪些资源共享方式?
AQS支持两种资源共享方式:独占模式和共享模式。独占模式下,同一时间内只有一个线程能获取资源,ReentrantLock是一个典型的独占模式的实现。共享模式下,允许多个线程同时访问资源,Semaphore和ReadWriteLock是共享模式的实现。
(3)AQS的底层数据结构和工作原理是什么?
AQS的底层数据结构是一个双向链表。AQS的核心是基于一个volatile变量来表示同步状态,以及一个通过节点构成的FIFO队列来管理等待的线程。当一个线程请求获取同步状态失败时,AQS会将该线程包装成一个节点放入队列尾部,并在适当的时候阻塞或唤醒节点中的线程;当同步状态释放时,头节点的线程将尝试再次获取同步状态,并在成功后移除队列并继续执行。
(4)什么是Condition?它有哪些使用场景?
Condition是用于线程间通信的一种工具,允许线程因等待某个条件成立而暂停执行,直到另一个线程在这个条件下成立时发出通知。Condition用于在共享资源达到某种特定状态时,控制线程的执行流程,常见使用场景包括实现生产者-消费者模式、实现公平的锁机制、多路等待/通知等。
(5)AQS中的Condition是如何实现的?
在AQS中,Condition功能通过内部类ConditionObject实现,它利用AQS的同步状态管理机制,为每个Condition维护一个等待队列。当线程调用Condition的await()方法时,线程会释放锁并被加入该Condition的等待队列中;当其他线程调用Condition的signal()方法时,等待队列中的线程将被移动到AQS的同步队列中,等待获取同步状态。
作为求职者,合理结构化这些子问题的答案,可以展示出我们对AQS的深入理解和实践经验,并有效提升面试官对我们的好感。
为了让求职者对AQS相关知识有更深入的掌握和理解,灵活应对面试细节,下面我们对上述解答要点逐个进行详解。
AQS是AbstractQueuedSynchronizer的缩写,其中文含义是抽象队列同步器。它是Java并发包中的一个关键抽象类,用于构建锁或其他同步器,ReentrantLock、Semaphore、ReentrantReadWriteLock等都是基于AQS实现的。JUC(Java Concurrency Utilities,Java并发编程工具包)的设计者Doug Lea(道格·利)期望它能够成为实现大部分同步需求的基础,作为JUC中的核心基础组件。
AQS使用一个int型成员来表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作,并通过一个双向链表(CLH锁队列)来管理这些排队的线程。下面是AbstractQueuedSynchronizer类的核心定义。
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable { // Node是AQS内部使用的队列节点,它用于构建一个CLH锁队列 // CLH锁是一种自旋锁,能确保无饥饿性,它保持着一个等待线程的队列 // AQS中的队列是一个变种,线程可能不会自旋,而是被阻塞 static final class Node {...} // 头节点,通常指向代表当前正在执行的线程的节点 // 如果当前没有线程持有锁,则为null // 该字段使用volatile修饰,以保证线程间的可见性 private transient volatile Node head; // 尾节点,指向队列中的最后一个节点 // 当一个新的线程加入队列时,它会被设置成新的尾节点 // 该字段使用volatile修饰,以保证线程间的可见性 private transient volatile Node tail; // 表示同步状态的变量。AQS使用这个变量来控制同步资源的获取与释放 // 在一个独占锁中,状态为0表示锁未被任何线程持有 // 而状态为1表示锁已被某个线程持有 // 该字段被声明为volatile,以保证线程间的可见性 private volatile int state; // 其他方法和字段 }
AQS的设计高度抽象,并且十分灵活。AQS负责管理同步状态、实现线程的排队和等待,以及唤醒等待的线程,开发者通过继承AQS可以扩展AQS并实现它的抽象方法,从而实现自己的同步器。
AQS的主要优点如下。
● 灵活:AQS支持两种资源共享方式,包括独占模式和共享模式,这使得AQS可以实现各种同步器,如ReentrantLock(采用独占模式)、ReadWriteLock(采用共享模式)、CountDownLatch(采用共享模式)、Semaphore(采用共享模式)、FutureTask(采用独占模式)等。
● 高效:通过使用高效的等待/通知机制,AQS可以减少锁竞争下的开销,并且在同步状态变化时只唤醒需要被唤醒的线程。
● 提供排队机制:AQS内部使用了一个FIFO队列来管理线程。这确保了等待锁的线程被公平地管理,并且按照请求锁的顺序被处理。
● 可重用:AQS提供了一组可重用的方法来管理同步状态,这简化了同步组件的开发,开发者只需要实现少量的方法就能定义自己的同步逻辑。
● 安全:AQS帮助开发者避免了同步时的许多常见陷阱,例如死锁、线程饥饿等,因为它将复杂的同步控制逻辑封装在内部,提供了易于使用的API(Application Program Interface,应用程序接口)。
AQS的设计极大地简化了复杂同步组件的实现,提高了并发编程的抽象级别。利用AQS我们可以实现以下功能。
● 构建独占锁:基于AQS能够构建独占锁,例如ReentrantLock,这种锁允许同一时间内只有一个线程执行临界区代码。
● 实现同步器:AQS可以用来实现多种同步器,如Semaphore、CountDownLatch和CyclicBarrier等。
● 构建读写锁:AQS能够支持构建读写锁(如ReentrantReadWriteLock),允许多个线程同时读取资源,而写入时则需要独占访问。
● 自定义同步组件:开发者可以基于AQS实现自定义的同步组件,可以创建具有特殊等待/通知逻辑的锁或其他同步机制。
● 等待多个条件:AQS配合Condition接口能够使线程在特定的条件下等待,提供类似Object的wait()和notify()的功能,但更加强大和灵活。
总体来讲,AQS是构建锁和同步器的强大工具,它不仅简化了同步组件的开发,同时提供了高性能的实现。
AQS支持两种资源共享方式:独占(Exclusive)模式和共享(Shared)模式。AQS只是一个抽象类,具体资源的获取、释放都由不同类型的同步器实现。
(1)独占模式。
独占模式意味着同一时间内只有一个线程可以获取资源。这是最常见的一种资源共享方式。在独占模式下,当线程试图获取资源时,如果资源已经被占用,则该线程必须等待,直到占用资源的线程释放资源。
独占模式是可重入的,即同一个线程可以再次获取资源,并且会对获取操作进行计数。当线程完成所有工作后,它释放资源的次数必须相同才能真正释放该资源。ReentrantLock是一种基于AQS独占模式的同步器实现。
(2)共享模式。
共享模式允许多个线程同时访问资源。共享模式在读多写少的场景中非常有用,例如在缓存实现中,通常读取操作远多于写入操作。
在共享模式中,同一资源可以由多个线程共享,AQS维护一个计数来跟踪可用的资源数量。线程尝试获取资源时会减少计数,释放资源时会增加计数。如果资源计数不为0,则请求资源的线程可以成功获取资源,否则线程会被阻塞,直到资源变为可用。
共享模式可以进一步分为以下两种情况。
● 完全共享:允许同时有多个线程共享资源。Semaphore和ReadWriteLock的读锁就是这样工作的。
● 条件共享:资源的共享程度取决于给定条件。例如CountDownLatch允许一个或多个线程等待其他线程完成一系列操作,直到计数为0,等待的线程才被允许继续执行。
(3)AQS中共享和独占的实现。
AQS定义了一些模板方法,具体资源的获取、释放需要由自定义同步器实现,通过继承并实现AQS这些模板方法来支持共享和独占的资源共享方式。
在实现自定义同步器时只需实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护,如获取资源失败入队、唤醒出队等,AQS已经在顶层实现好,不需要具体的同步器进行处理。自定义同步器的主要方法如表1-2所示。
表1-2
方法 |
模式 |
描述 |
---|---|---|
tryAcquire(int) |
独占模式 |
尝试获取资源 |
tryRelease(int) |
独占模式 |
尝试释放资源 |
tryAcquireShared(int) |
共享模式 |
尝试获取资源 |
tryReleaseShared(int) |
共享模式 |
尝试释放资源 |
AQS通过内部队列来管理线程竞争资源时的等待状态,并通过acquire()、acquireShared()、release()和releaseShared()等方法提供了高层次的同步机制,这些方法会在适当的时候触发调用,具体的同步器需要根据其资源共享的语义来实现这些方法。
总之,AQS通过共享模式和独占模式为Java并发包下的同步器提供了一个强大且灵活的基础,同时允许开发者自定义构建各种同步器,并应对复杂的并发场景。
AQS的底层数据结构是一个双向链表。这个双向链表主要用于维护等待获取锁的线程队列。在AQS中,每个等待的线程都被封装成一个节点(Node类的实例)并被加入队列,如图1-7所示。
图1-7
当一个线程请求获取同步状态失败时,AQS会将该线程包装成一个节点放入队列尾部,并在适当的时候阻塞或唤醒节点中的线程;当同步状态释放时,头节点的线程将尝试再次获取同步状态,并在成功后移除队列并继续执行。节点是构成同步队列和等待队列(Condition)的基础,同步器拥有头节点和尾节点,同步状态获取失败的线程会加入该队列的尾部。通过CAS来加入队列并设置尾节点。
下面我们通过AQS类的实现源码详细介绍它的底层数据结构和工作原理。
(1)同步状态。
AQS内部定义了一个int型变量state,AQS使用这个变量来控制同步器(例如锁)的获取与释放,状态为0表示锁未被任何线程持有,而状态为1表示锁已被某个线程持有。将state声明成volatile的,以此保证线程间的可见性。
AQS中state相关的代码逻辑如下。
private volatile int state; protected final int getState() { return state; } protected final void setState(int newState) { state = newState; } protected final boolean compareAndSetState(int expect, int update) { // 省略其他代码 }
(2)Node类。
AQS内部定义了一个静态嵌套类Node,它用于表示双向链表中的节点。每个Node实例都包含以下几个关键属性。
● thread:存储当前节点所对应的线程。
● prev:指向当前节点的前驱节点。
● next:指向当前节点的后继节点。
● waitStatus:表示节点的等待状态,比如表示节点是否需要被唤醒、是否被取消等。
Node类的代码实现逻辑如下。
static final class Node { volatile Node prev; volatile Node next; volatile Thread thread; volatile int waitStatus; // 省略其他代码 }
(3)操作双向链表。
AQS通过上述Node类形成一个双向链表来管理等待锁的线程,使用head和tail两个字段分别跟踪双向链表的头节点和尾节点。
● 头节点(head):表示双向链表的开始,通常不存储任何线程,实际持有锁或第一个获取锁的线程会被设置为头节点。
● 尾节点(tail):表示双向链表的结束,新加入的线程被置于队列的尾部。
AQS中head和tail相关的代码逻辑如下。
private transient volatile Node head; private transient volatile Node tail;
head和tail字段使用volatile定义,是为了确保其在多线程环境下的可见性和有序性。
双向链表的入列操作方法有addWaiter()和enq(),这两个方法的作用相似,都用于将节点加入链表中。addWaiter()方法的主要作用是为当前的线程创建一个新的节点,并尝试将这个新节点安全地添加到AQS的同步队列的尾部。addWaiter()方法的代码实现逻辑如下。
private Node addWaiter(Node mode) { // 初始化一个节点,将当前线程设置为节点的线程,并设置节点的模式 Node node = new Node(mode); // 无限循环,尝试将节点添加到同步队列的尾部 for (;;) { // 获取当前队列的尾节点 Node oldTail = tail; if (oldTail != null) { // 如果尾节点存在 // 使用setPrevRelaxed()设置当前节点的前驱节点为尾节点 node.setPrevRelaxed(oldTail); // 使用CAS操作尝试将当前节点设置为新的尾节点 if (compareAndSetTail(oldTail, node)) { // 如果CAS操作成功,则将旧的尾节点的后继节点设置为当前节点 oldTail.next = node; // 返回新加入的节点 return node; } } else { // 如果尾节点不存在,则初始化同步队列 initializeSyncQueue(); } } }
上述代码的主要逻辑是将当前线程包装成一个节点,并尝试将其安全地添加到同步队列的尾部。代码中的无限循环和CAS操作是为了在多线程环境中正确和安全地管理节点的添加过程,这对于锁机制的正确性至关重要。
enq()方法是一个私有方法,专门负责在必要时初始化队列,在底层确保节点可以被线程安全地添加到队列中,而不是直接被AQS使用者调用。enq()方法的代码实现逻辑如下。
private Node enq(Node node) { // 无限循环,尝试将节点插入队列 for (;;) { // 获取当前的尾节点 Node oldTail = tail; // 如果尾节点不为null,则队列已经初始化 if (oldTail != null) { // 使用setPrevRelaxed()方式设置当前节点的前驱节点为旧的尾节点 node.setPrevRelaxed(oldTail); // 使用CAS操作尝试更新尾节点为新节点 if (compareAndSetTail(oldTail, node)) { // 如果CAS操作成功,则将旧的尾节点的next引用指向新节点 oldTail.next = node; // 返回新节点的前驱节点,也就是旧的尾节点 return oldTail; } // 如果CAS操作失败,则说明其他线程已经插入了节点,循环将继续尝试 } else { // 如果尾节点为null,则说明队列没有初始化 // 需要初始化同步队列 initializeSyncQueue(); // 初始化完成后,循环将继续尝试插入节点 } } }
enq()方法是AbstractQueuedSynchronizer类的一部分,负责将一个节点插入队列中。如果队列没有初始化(即尾节点为null),该方法会首先初始化同步队列。该方法使用无限循环结合CAS操作来确保节点的正确插入,这是因为在多线程环境中,可能会有多个线程同时尝试插入节点,使用CAS操作可以确保节点的插入是原子性的,是线程安全的。
(4)获取和释放资源。
AQS提供了一些获取和释放资源的模板方法,基于AQS的同步器需要实现这些方法,如下所示。
protected boolean tryAcquire(int arg) { throw new UnsupportedOperationException(); } protected boolean tryRelease(int arg) { throw new UnsupportedOperationException(); } protected int tryAcquireShared(int arg) { throw new UnsupportedOperationException(); } protected boolean tryReleaseShared(int arg) { throw new UnsupportedOperationException(); }
● tryAcquire()方法:尝试以独占模式获取资源,如果资源可获取(例如,锁未被其他线程持有),则线程会获取该资源并返回true;否则,返回false。该方法用于实现独占模式的锁,例如ReentrantLock。
● tryRelease()方法:尝试以独占模式释放资源。如果当前线程可以释放资源(例如,持有的锁可以被释放),则执行释放操作并返回true;否则,返回false。该方法用于实现独占模式的锁释放机制,并确保资源被正确释放。
● tryAcquireShared()方法:尝试以共享模式获取资源。根据资源的状态,决定线程是否可以获取共享资源。返回正值表示成功,返回0或负值表示失败。该方法用于实现共享资源的同步机制,例如Semaphore或ReadWriteLock的读锁。
● tryReleaseShared()方法:尝试以共享模式释放资源。如果当前资源可以被释放,则执行释放操作并返回true;否则,返回false。成功释放可能会唤醒等待的线程。该方法用于共享模式下的资源释放。
通过上面的模板方法,AQS实现了完整的资源获取和释放的流程。下面以独占模式获取和释放资源为例介绍具体实现过程。
独占模式获取资源的方法为acquire(),它会调用模板方法tryAcquire()尝试获取资源,如果获取失败,则调用acquireQueued()进入等待队列。acquire()方法的代码实现逻辑如下。
// 尝试获取资源。如果获取不成功,则进入等待队列 public final void acquire(int arg) { // 尝试直接获取资源,如果成功,直接返回;如果失败,则进行下一步 if (!tryAcquire(arg) && // 尝试获取失败,将当前线程封装为节点后加入等待队列,并尝试获取资源 acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) // 如果在等待过程中线程被中断,重新设置当前线程为中断状态 selfInterrupt(); } // 在等待队列中获取资源,如果线程被中断,则返回true final boolean acquireQueued(final Node node, int arg) { boolean interrupted = false; // 记录线程是否被中断 try { for (;;) { // 自旋等待获取资源 final Node p = node.predecessor(); // 获取当前节点的前驱节点 // 如果当前节点的前驱节点是头节点,并且尝试获取资源成功,那么当前线程已获取资源 if (p == head && tryAcquire(arg)) { setHead(node); // 当前节点成为新的头节点 p.next = null; // 清理原先的头节点 return interrupted; // 返回线程是否被中断 } // 如果应该将线程挂起等待获取资源,则挂起线程并检查中断状态 if (shouldParkAfterFailedAcquire(p, node)) interrupted |= parkAndCheckInterrupt(); } } catch (Throwable t) { // 如果出现异常,取消获取资源 cancelAcquire(node); if (interrupted) // 如果线程在等待过程中被中断,则保持线程的中断状态 selfInterrupt(); throw t; // 继续抛出异常 } } // 该方法用于自我中断,即重新设置当前线程的中断状态 static void selfInterrupt() { Thread.currentThread().interrupt(); }
独占模式释放资源的方法为release(),它会调用模板方法tryRelease()进行资源释放,如果资源释放成功,会检查同步队列,并在必要时唤醒队列中的后继节点。release()方法的代码实现逻辑如下。
// 释放资源,释放成功则返回true,失败则返回false public final boolean release(int arg) { // 尝试释放资源,该操作是在子类中实现的 if (tryRelease(arg)) { // 释放成功后,获取同步队列的头节点 Node h = head; // 如果头节点存在,并且其等待状态不为0(表示后继节点可能需要被唤醒) if (h != null && h.waitStatus != 0) // 唤醒头节点的后继节点,即队列中等待时间最长的那个节点 unparkSuccessor(h); // 释放资源成功,返回true return true; } // 释放资源失败,返回false return false; }
除了上述详解的acquire()和release()方法,AQS提供了一组用于获取和释放资源的方法,这些方法的详情如表1-3所示。
表1-3
方法 |
模板方法 |
功能描述 |
---|---|---|
acquire() |
tryAcquire() |
以独占模式获取资源,如果获取失败,则将线程加入等待队列并在必要时进行阻塞 |
acquireInterruptibly() |
tryAcquire() |
以独占模式获取资源,允许中断资源的获取过程 |
tryAcquireNanos() |
tryAcquire() |
以独占模式在给定的时间内获取资源,如果不能获取则返回 |
acquireShared() |
tryAcquireShared() |
以共享模式获取资源,如果获取失败,则将线程加入等待队列并在必要时进行阻塞 |
acquireSharedInterruptibly() |
tryAcquireShared() |
以共享模式获取资源,允许中断资源的获取过程 |
tryAcquireSharedNanos() |
tryAcquireShared() |
以共享模式在给定的时间内获取资源,如果不能获取则返回 |
release() |
tryRelease() |
以独占模式释放资源,如果成功则唤醒等待的线程 |
releaseShared() |
tryReleaseShared() |
以共享模式释放资源,并且唤醒后续等待的线程 |
(5)线程阻塞和唤醒。
AQS中关于线程阻塞的方法是parkAndCheckInterrupt(),关于线程唤醒的方法是unparkSuccessor(),这两个方法在底层使用LockSupport类的park()和unpark()方法来实现。parkAndCheckInterrupt()方法的代码实现逻辑如下。
//该方法用于挂起当前线程,并在线程被唤醒时检查线程是否被中断 private final boolean parkAndCheckInterrupt() { // 调用LockSupport.park()方法挂起当前线程 LockSupport.park(this); // 调用Thread.interrupted()检查线程是否被中断,如果线程在挂起期间被中断过,则返回true return Thread.interrupted(); }
unparkSuccessor()方法的代码实现逻辑如下。
// 该方法用于唤醒给定节点的后继节点 private void unparkSuccessor(Node node) { int ws = node.waitStatus; // 如果节点的等待状态为负值,则表示后继节点可能在等待信号 if (ws < 0) // 尝试将节点的等待状态设置为0以预备释放信号 node.compareAndSetWaitStatus(ws, 0); // 将下一个要唤醒的线程保存在节点的s字段中 Node s = node.next; //但如果后继节点被取消或为空 if (s == null || s.waitStatus > 0) { s = null; // 从尾部向前遍历,以找到真正的、未被取消的后继节点 for (Node p = tail; p != node && p != null; p = p.prev) if (p.waitStatus <= 0) s = p; } // 如果找到了合适的后继节点,唤醒该节点的线程 if (s != null) LockSupport.unpark(s.thread); }
AQS的实现非常复杂,包括对并发和线程调度的深入控制以及对中断、超时、条件等高级特性的支持。上述代码片段只是AQS的冰山一角,但它们展示了其设计的精髓。总之,AQS的双向链表结构为实现高效且灵活的同步控制奠定了坚实的基础。
在传统的线程同步中,我们使用synchronized关键字来锁定一段代码,或者使用Object的wait()和notify()方法来协调线程的执行。然而,这些方法都有一些限制。例如wait()和notify()方法不能很好地处理多个条件的情况,而且它们不支持公平性排序。为了解决这些问题,AQS引入了Condition。
Condition是一个接口,它提供了与Object.wait9()和Object.notify()相同的功能。Condition是AQS中的一个重要组件,它为线程提供了一种机制,可以在满足某个条件时挂起或唤醒线程。Doug Lea在Condition接口的描述中提到了这点。
Conditions (also known as condition queues or condition variables) provide a means for one thread to suspend execution (to "wait") until notified by another thread that some state condition may now be true.
简单来讲,Condition(条件队列或条件变量)是用于线程间通信的一种工具,允许线程因等待某个条件成立而暂停执行,直到另一个线程在这个条件下成立时发出通知。
Condition接口的定义源码,如下所示。
public interface Condition { void await() throws InterruptedException; void awaitUninterruptibly(); long awaitNanos(long nanosTimeout) throws InterruptedException; boolean await(long time, TimeUnit unit) throws InterruptedException; boolean awaitUntil(Date deadline) throws InterruptedException; void signal(); void signalAll(); }
Condition只提供了两个功能——等待(await())和唤醒(signal()),与Object提供的等待与唤醒相似。
比如我们在使用ReentrantLock时,可以使用Condition来协调多个线程对共享资源的访问。当某个线程需要访问共享资源时,该线程可以调用lock()方法获取锁,然后调用Condition的await()方法将当前线程放入等待队列中等待唤醒。其他线程在访问完共享资源后,可以调用Condition的signal()方法唤醒等待队列中的一个线程。这样就可以实现线程间的协调和同步,避免对共享资源的竞争和冲突。
Condition接口在Java并发编程中提供了一种更加灵活的线程等待/通知机制,相比于Object类中的wait()、notify()和notifyAll()方法,Condition提供的操作更丰富,以下是它的一些常见使用场景。
(1)等待特定条件满足。
Condition可以用于在某个条件不满足时挂起线程,并在条件可能已经满足时唤醒线程。例如,在生产者-消费者问题中,消费者线程可以在队列为空时等待,而生产者线程在添加元素到队列后通知消费者继续执行。
(2)实现生产者-消费者模式。
使用两个Condition实例,一个用于通知“不为空”的条件,另一个用于通知“不为满”的条件。这样可以精确地通知某一个等待的线程,而不是像使用notifyAll()方法那样通知所有等待的线程。
(3)实现公平的锁机制。
Condition对象可以与可重入锁(例如ReentrantLock)一起使用来实现公平的锁机制,其中线程按照它们请求访问的顺序获得锁。
(4)多路等待/通知。
一个锁可以关联多个Condition对象,这意味着可以有多组线程等待锁的不同条件。比如,在有一个共享资源但不同线程等待不同条件的场景中,可以为每个不同的条件创建一个Condition实例。
(5)选择性通知。
当有多个等待条件时,Condition提供选择性通知,使用signal()可以只唤醒某一个等待的线程,而不是像使用signalAll()那样唤醒所有等待的线程。
(6)实现阻塞队列和其他同步组件。
Condition经常在自定义的阻塞队列中实现,或在其他需要多线程协作控制的数据结构中实现,用于控制线程的休眠和唤醒。
在AQS中提供了一个内部类ConditionObject,每个Condition对象都是一个等待队列,遵守FIFO规则,通常也被称为条件队列,一个同步器可以拥有一个同步队列和多个等待队列。等待队列使用Node节点来存储等待线程。每个Node节点包含3个部分:线程、共享状态和后继节点。在当前线程调用Condition的await()方法时,将会以当前线程构造节点,并将该节点从尾部加入等待队列;在调用Condition的signal()方法时,会从等待队列中取出头节点,并将该节点加入同步队列中,等待获取资源。
通过对AQS中ConditionObject核心源码的分析,可以知晓其中的实现原理和处理流程,核心源码如下所示。
(1)等待队列。
当线程调用await()方法时,它会释放当前持有的锁,并且被加入Condition对象相关联的等待队列中。这个等待队列完全由AQS的节点组成,每个节点代表一个线程。await()方法的实现源码和处理步骤如下。
public final void await() throws InterruptedException { // 检查当前线程是否已经中断,如果是,则抛出InterruptedException if (Thread.interrupted()) throw new InterruptedException(); // 将当前线程封装成节点并添加到条件队列中等待 Node node = addConditionWaiter(); // 完全释放当前线程持有的锁,并让线程返回释放前的状态,便于以后能够恢复这个状态 int savedState = fullyRelease(node); // 初始化一个变量来记录线程的中断模式 int interruptMode = 0; // 如果节点不在同步队列上,则线程应该被挂起 while (!isOnSyncQueue(node)) { // 挂起当前线程 LockSupport.park(this); // 检查在等待过程中线程是否被中断,根据中断类型设置interruptMode的值 if ((interruptMode = checkInterruptWhileWaiting(node)) != 0) break; } // 当节点成功地加入同步队列后,尝试以之前保存的状态值去获取锁 // 如果在此过程中线程被中断,且中断模式不是THROW_IE // 则将interruptMode设置为REINTERRUPT if (acquireQueued(node, savedState) && interruptMode != THROW_IE) interruptMode = REINTERRUPT; // 如果节点的nextWaiter不为空,则意味着可能有取消等待的节点,执行清理操作 if (node.nextWaiter != null) unlinkCancelledWaiters(); // 如果interruptMode不为0,则说明线程在等待过程中被中断过,需要处理这个中断 if (interruptMode != 0) reportInterruptAfterWait(interruptMode); }
await()方法的核心处理逻辑是使得一个线程在某个条件变量上等待,直到它被另一个线程唤醒。在等待期间,该线程会释放之前持有的锁,并在被唤醒后尝试重新获取锁。此外,它还处理了线程中断。
(2)节点状态。
线程被封装在节点中,节点状态用于标识线程是否在等待条件。使用addConditionWaiter()方法可以将被封装的线程放入Condition的等待队列中等待,直到该线程被signal()或signalAll()方法唤醒。addConditionWaiter()方法的实现源码和处理步骤如下。
private Node addConditionWaiter() { // 检查当前线程是否持有独占锁,如果没有,则抛出IllegalMonitorStateException异常 if (!isHeldExclusively()) throw new IllegalMonitorStateException(); // 获取条件队列的最后一个等待者节点 Node t = lastWaiter; // 如果最后一个等待者节点被取消,即它的等待状态不是CONDITION // 那么清除所有被取消的节点 if (t != null && t.waitStatus != Node.CONDITION) { unlinkCancelledWaiters(); // 清除操作 t = lastWaiter; // 清除完毕后再次获取最后一个等待者节点,因为最后一个等待者节点可能有变动 } // 创建一个新的节点,将它的状态设置为CONDITION,表示它是一个等待者节点 Node node = new Node(Node.CONDITION); // 如果条件队列为空,则将此新节点设置为队列的第一个等待者节点 if (t == null) firstWaiter = node; // 否则,连接这个新节点到最后一个等待者节点的后面 else t.nextWaiter = node; // 更新最后一个等待者节点为这个新节点 lastWaiter = node; // 返回这个新节点 return node; }
图1-8
addConditionWaiter()方法是AQS的一个私有辅助方法,用于将一个新的等待者节点加入等待队列中。该方法首先检查调用这个方法的线程是否持有相应的锁,然后创建新的节点,并将它加入等待队列的末尾,最后返回这个新节点。如果队列中存在已被取消的节点,该方法还会负责清除这些节点,以避免潜在的内存泄漏和性能问题。
(3)唤醒过程。
当调用signal()或signalAll()方法时,线程节点从Condition的等待队列移动到AQS同步队列。在这个过程中,线程的状态从等待条件状态变为等待获取锁的状态。当线程在等待队列中被唤醒,它将尝试重新获取之前释放的锁。signal()方法的实现源码和处理步骤如下。
public final void signal() { // 检查当前线程是否有权利执行唤醒操作,即它是否拥有锁 if (!isHeldExclusively()) throw new IllegalMonitorStateException(); // 获取等待队列中的第一个线程节点 Node first = firstWaiter; // 如果存在线程节点,调用doSignal()方法唤醒它 if (first != null) doSignal(first); } private void doSignal(Node first) { do { // 移除等待队列的线程头节点,并尝试将其转移到同步队列中 if ((firstWaiter = first.nextWaiter) == null) lastWaiter = null; // 如果这是唯一的节点,清空队列 first.nextWaiter = null; // 清除节点的nextWaiter引用 // 如果线程节点成功转移至同步队列,或者等待队列为空,则退出循环 } while (!transferForSignal(first) && (first = firstWaiter) != null); } final boolean transferForSignal(Node node) { // 尝试将节点状态从CONDITION改为0,如果失败则表示节点已被取消 if (!node.compareAndSetWaitStatus(Node.CONDITION, 0)) return false; // 将节点加入同步队列末尾,并返回其前驱节点 Node p = enq(node); int ws = p.waitStatus; // 如果前驱节点已取消或无法设置其状态为SIGNAL,则直接唤醒节点线程 if (ws > 0 || !p.compareAndSetWaitStatus(ws, Node.SIGNAL)) LockSupport.unpark(node.thread); return true; }
在上述代码中,signal()方法用于唤醒等待队列中的第一个等待者节点。doSignal()方法负责实际将等待队列的头节点转移到同步队列中,从而使得这些节点能够在锁释放时被唤醒。transferForSignal()方法通过改变节点的状态和将其加入同步队列中来完成节点的转移。
通过上述过程的分析,我们可知线程是如何在Condition的等待队列和AQS同步队列中转移的,具体如图1-8所示。
AQS提供了ConditionObject类作为Condition接口的一个实现,多数同步器使用这个类来创建与之关联的Condition实例,也有些同步器会自定义实现,例如ReentrantLock通常会通过AQS提供的方法来实现自己的Condition逻辑。
CAS(Compare And Swap,比较并交换)是一种用来实现多线程同步的原子指令,被广泛应用于实现无锁编程,如原子类和某些并发数据结构等。面试官提出“讲讲CAS实现机制和原理”,不仅是为了检验求职者对并发编程中基础同步技术的掌握程度,还是为了深入了解求职者对CAS的实际应用水平和对CAS相关问题的处理方式。
我们在回答这个问题时,可以将其分解成以下子问题,更全面、有条理地回答面试官,具体解答思路如下。
(1)什么是CAS?它有什么作用?
CAS是一种无锁的同步机制,用于实现多线程同步的原子操作。它检查内存中某个变量的值是否为预期值,如果是,则将该值更新为新值。这个机制支持构建非阻塞的并发数据结构,有效减少了线程阻塞的情况,提高了系统的并发性和吞吐量。
(2)Java中有哪些CAS工具?如何使用它们?
Java在java.util.concurrent.atomic包中提供了一系列基于CAS实现的原子类,如AtomicInteger、AtomicLong、AtomicReferenceArray等。这些类提供了一组原子操作的方法,如getAndIncrement()、compareAndSet()等,它们用于在多线程环境下安全地执行单变量操作,从而无须使用synchronized关键字。
(3)Unsafe类和CAS有什么关系?
Unsafe类是Java提供的一个“后门”,它允许Java代码直接访问底层资源和执行低级别、不安全操作,包括CAS操作。在Java的Atomic类内部,大量使用Unsafe类提供的compareAndSwapInt()、compareAndSwapLong()、compareAndSwapObject()等方法来实现变量的原子性更新。虽然Unsafe类并不是设计给普通开发者使用的,但它是实现CAS操作的关键。
(4)使用CAS会产生什么问题?如何解决这些问题?
● ABA问题:当内存位置V初次读取为A值,后被改为B值,然后又改回A值,使用CAS进行比较时,会误认为没有改变。解决方案之一是引入版本号,每次变量更新时递增版本号,比较时同时比较版本号。
● 循环时间长、CPU开销大问题:在高并发情况下,CAS操作可能需要多次重试才能成功,导致CPU资源的浪费。解决方案包括使用限制重试次数的策略,或者当检测到高冲突时,退回到使用锁的策略。
此外,使用CAS会产生饥饿问题、只能保障单个变量的原子性问题、伪共享问题等,这些问题的现象描述和解决方法在1.5.4小节有详细介绍。
作为求职者,通过细致回答上述子问题,我们可以展示出对CAS机制及其在Java中应用的深入了解。同时,通过讨论使用CAS会产生的问题及对应的解决方案,可以进一步展现出我们的问题解决能力和实际经验,这对于面试成功是非常有帮助的。
为了让大家对CAS实现机制和原理有更深入的掌握和理解,灵活应对面试细节,下面我们对上述解答要点逐个进行详解。
在多个线程同时访问同一个共享资源时可能出现竞争问题,为了保证数据的一致性和正确性,我们通常采取同步机制来对共享资源进行加锁。但是,传统的锁机制在高并发场景下会带来严重的性能问题,因为所有线程都需要等待锁的释放才能进行操作,这会导致大量线程的阻塞和唤醒,进而降低系统的并发性能。
为了解决上述问题,CAS应运而生。CAS是一种非阻塞式并发控制技术,可以在不使用锁的情况下实现数据的同步和并发控制。
CAS是一种无锁的同步机制,用于实现多线程同步的原子性操作。CAS操作涉及3个操作数,分别是内存变量(V)、预期值(A)和新值(B)。
当多个线程使用CAS操作一个变量时,可以保障以下过程的原子性。
● 检查变量V中的值是否等于预期值A。
● 如果变量V中的值等于A,就将变量V中的值更新为新值B。
● 如果变量V中的值不等于A,就放弃更新操作。
“原子性”意味着上述操作是不可中断的,在检查值操作和更新值操作之间不会有其他操作插入。在多个线程中只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试上述操作。
在多线程编程中,CAS常见的应用场景和作用如下。
(1)无锁同步。
CAS提供了一种在不使用传统锁(如互斥锁)的情况下进行线程同步的方法。使用这种方法可以减少线程之间的阻塞和上下文切换,提高系统的并行性能。
(2)构建原子性操作。
CAS支持一些高级的原子性操作,如原子变量上的递增、递减、加法等,都是基于CAS实现的。
(3)实现并发数据结构。
CAS被广泛用于实现并发数据结构(如原子计数器、无锁队列和栈等),因为它能确保在多线程环境下数据结构状态的一致性。
(4)实现乐观锁定机制。
在数据库和软件事务内存(Software Transactional Memory,STM)中,CAS可以用于实现乐观锁定机制,其中每次操作都假设没有冲突,只在提交时检查是否有其他线程已经更改了数据。
CAS操作的具体优点可以总结为以下几方面。
● 开销小:CAS 操作不需要加锁,因此可以避免加锁操作所带来的性能开销。
● 一致:CAS 操作是原子性的,因此可以保证操作的一致性。
● 无阻塞:CAS 操作不会阻塞线程,因此可以避免线程的切换和上下文切换。
CAS操作是现代多CPU系统中支持并行算法的基础之一,它是构建出高效、可扩展并发算法的重要工具。
在Java中,CAS操作主要通过java.util.concurrent.atomic包提供的一系列原子类来实现。这些原子类利用底层的CAS硬件指令来保证对单个变量操作的原子性,从而支持无锁的并发编程。下面介绍一些常见的CAS工具和它们的用途。
(1)基本类型的原子类。
● AtomicInteger:一个可以原子性更新的int型值。
● AtomicLong:一个可以原子性更新的long型值。
● AtomicBoolean:一个可以原子性更新的boolean型值。
(2)数组类型的原子类。
● AtomicIntegerArray:int型数组,其中的元素可以原子性更新。
● AtomicLongArray:long型数组,其中的元素可以原子性更新。
● AtomicReferenceArray:对象引用数组,其中的元素可以原子性更新。
(3)引用类型的原子类。
● AtomicReference<V>:一个可以原子性更新的对象引用。
● AtomicStampedReference<V>:一个带有int型标记的对象引用,可以原子性更新对象引用及其标记。这个类主要用于解决CAS操作中的ABA问题。
● AtomicMarkableReference<V>:一个带有boolean型标记的对象引用,可以原子性更新对象引用及其标记,其用途与AtomicStampedReference的类似,但它只关心标记的有无,而不关心标记的具体值。
(4)字段更新器。
● AtomicIntegerFieldUpdater<T>:用于原子性更新某个对象中的int型字段。
● AtomicLongFieldUpdater<T>:用于原子性更新某个对象中的long型字段。
● AtomicReferenceFieldUpdater<T,V>:用于原子性更新某个对象中的引用类型字段。
(5)增强型原子类。
● LongAdder和DoubleAdder:提供了在高并发下比AtomicLong更好的性能,适用于统计计数器和累加器。
● LongAccumulator和DoubleAccumulator:功能更为强大的累加器,可以指定累加逻辑。
这些CAS工具提供了一系列方法,如get()、set()、getAndIncrement()、compareAndSet()等,使得在多线程环境中,对共享变量的操作无须锁定,就能达到线程安全,并提高性能。我们以一个引用类型的原子类为例,利用AtomicReference管理一个线程安全的共享对象,具体使用方法如下。
import java.util.concurrent.atomic.AtomicReference; public class SharedResource { private final AtomicReference<Object> ref = new AtomicReference<>(); public void set(Object newValue) { ref.set(newValue); } public Object get() { return ref.get(); } public boolean compareAndSet(Object expectedValue, Object newValue) { return ref.compareAndSet(expectedValue, newValue); } }
通过上述代码,我们可以看到,这些原子类在使用时不需要考虑底层实现问题,非常简单、方便,但同时需要仔细考虑它们在特定场景下的适用性和性能影响等问题。
Unsafe类是Java中一个特殊的类,它提供了一组用于执行低级别、不安全操作的方法,这些操作通常包括直接内存访问、线程的挂起与恢复,以及基于CAS操作的变量更新等。Unsafe类并不是Java标准库的一部分,而是Sun的专有API,因此Oracle官方并不推荐直接使用它,但在实践中,它在Java的内部和一些Java构建的并发工具中被广泛使用。
虽然在Java中实现CAS基于底层硬件支持,但该实现主要通过sun.misc.Unsafe类暴露给JVM进行底层操作。Java提供的java.util.concurrent.atomic包中有一系列原子类,这些类在内部也是使用Unsafe类来提供线程安全和原子性保证的。
以下是在Java中实现CAS的基本原理。
(1)原子指令。
CPU提供了原子指令CAS,这是一种多步骤操作,它在单个指令中能够完成原子性比较和替换的任务。这意味着,在比较和替换的过程中,不会有其他线程能够干扰该操作。
(2)Unsafe类。
Unsafe类直接与操作系统的本地方法交互,提供了一种执行CAS操作的方式。Unsafe类中的compareAndSwapInt()、compareAndSwapLong()和compareAnd-SwapObject()方法实际上对应的是本地方法调用,能够调用硬件级别的原子指令CAS。
(3)CAS操作。
下面看一段简单的CAS操作代码。
public boolean compareAndSwap(int expectedValue, int newValue) { return unsafe.compareAndSwapInt(this, valueOffset, expectedValue, newValue); }
其中unsafe是Unsafe类的实例;valueOffset是变量在内存中的偏移量,可以通过Unsafe的objectFieldOffset()方法获取;expectedValue是预期值;newValue是要更新的新值。
CAS操作的一个重要特点是它具有非阻塞特性,它不涉及锁的概念。因此,如果多个线程同时尝试进行CAS操作,它们不会阻塞,而是立即返回成功或失败的结果。这使得CAS特别适用于构建无锁数据结构和算法。而Unsafe类通过提供底层的CAS操作支持,使Java程序能够实现高效和复杂的并发控制策略,但使用时需要格外小心。
使用CAS操作进行非阻塞同步,虽然能很高效地解决原子性问题,但是仍然存在着以下几个问题。
(1)ABA问题。
ABA问题发生在一个线程读取一个位置的值A,然后这个线程被挂起的情况下。在此期间,另一个线程将该位置的值改为B,然后又改回A。当第一个线程恢复时,它看到的值仍然是A,并认为没有发生变化,然后继续进行CAS操作。尽管表面上看起来没有问题,但实际上这个位置的值已经变化过了,这可能会导致错误的行为。
解决方法:一种常见的解决方法是使用版本号或标记。设置一个版本号,每次变量更新时,这个版本号都会增加。因此,CAS操作不仅比较值,还会比较版本号。如果版本号不匹配,操作就会失败。
假如我们使用CAS来实现一个栈的pop操作,核心代码如下。
class Stack { private AtomicReference<Node> top = new AtomicReference<Node>(); public Node pop() { Node oldTop; Node newTop; do { oldTop = top.get(); if (oldTop == null) { return null; } newTop = oldTop.next; } while (!top.compareAndSet(oldTop, newTop)); // CAS操作 return oldTop; } // 其他方法省略 }
在上述代码中,如果一个线程尝试执行pop操作,而在这个过程中另一个线程执行pop操作,然后对相同的节点执行push操作,第一个线程在继续执行时会认为栈顶没有变化,因为它看到的值(即栈顶的引用)仍与之前的值相同。
解决这个ABA问题的方法是使用版本号或标记。我们可以增加一个版本号到栈的节点中,代码如下。
class Node { final int value; final long version; // 增加版本号 Node(int value, long version) { this.value = value; this.version = version; } // 其余实现省略 } class Stack { private AtomicStampedReference<Node> top = new AtomicStampedReference<Nod-e>(null, 0); public Node pop() { Node oldTop; int[] oldStamp = new int[1]; do { oldTop = top.getReference(); int stamp = top.getStamp(); oldStamp[0] = stamp; if (oldTop == null) { return null; } Node newTop = oldTop.next; } while (!top.compareAndSet(oldTop, newTop, oldStamp[0], oldStamp[0] + 1)); // 使用带版本号的CAS操作 return oldTop; } // 其他方法省略 }
在上述代码中,我们使用了AtomicStampedReference而不是简单的AtomicReference。AtomicStampedReference会同时检查节点和版本号,如果版本号有变化,即使节点相同,CAS操作也会失败。
(2)循环时间长、CPU开销大问题。
在高并发环境中,CAS操作可能需要多次循环才能成功,这可能会导致较大的CPU开销。
解决方法:可以引入退避策略,即在连续失败后暂停一段时间,或者在一定次数的失败后使用传统的锁。比如对于一个简单的计数器,我们使用CAS来增加计数。
class Counter { private AtomicInteger value = new AtomicInteger(); public void increment() { int current; do { current = value.get(); } while (!value.compareAndSet(current, current + 1)); // CAS操作 } }
在高并发环境下,很多线程同时尝试更新同一个计数器,可能会造成大量循环。此时我们可以引入退避策略来减少竞争,代码如下。
class Counter { private AtomicInteger value = new AtomicInteger(); public void increment() { int current; while (true) { current = value.get(); if (value.compareAndSet(current, current + 1)) { return; } else { // 退避策略 try { Thread.sleep((int) (Math.random() * 10)); } catch (InterruptedException e) { // 异常处理省略 } } } } }
在上述代码中,如果CAS失败,我们就让线程随机睡眠一段时间再重试,这样可以减少线程的竞争,并且可能会提高系统总体的成功率。
(3)饥饿问题。
在极端情况下,高优先级的线程可能会一直抢夺低优先级线程的执行资源,导致低优先级线程永远无法成功执行CAS操作。
解决方法:采用公平锁或其他调度技术可以确保所有线程都有机会成功执行。例如有多个线程尝试使用CAS操作更新同一个共享资源,但由于某些线程始终无法成功完成更新,可能会出现饥饿问题,核心示例代码如下。
class SharedResource { private AtomicInteger state = new AtomicInteger(0); public void update(int newValue) { while (!state.compareAndSet(state.get(), newValue)) { // 一直尝试,直到成功 } } }
在上述代码中,如果有大量线程同时尝试调用update()方法,一些线程可能需要很多次循环才能调用成功,甚至可能永远不能调用成功,从而出现饥饿问题。可以使用锁来确保所有线程都有机会执行更新,或者使用FIFO队列来公平地安排线程执行,代码如下。
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; class SharedResource { private int state = 0; private Lock lock = new ReentrantLock(true); // 公平锁 public void update(int newValue) { lock.lock(); try { state = newValue; // 在锁的保护下更新状态 } finally { lock.unlock(); } } }
在上述代码中,我们使用公平锁ReentrantLock确保按照线程请求锁的顺序来获取锁,从而避免出现饥饿问题。
(4)只能保证单个变量的原子性问题。
CAS只能保证单个共享变量完成原子更新。如果有多个变量需要同时更新,仅使用CAS将无法完成。
解决方法:可以使用锁或软件事务内存来保证多个变量同时更新的原子性。例如有一个简单的坐标类,用CAS来更新x和y坐标,代码如下。
class Coordinates { private AtomicInteger x = new AtomicInteger(0); private AtomicInteger y = new AtomicInteger(0); // 用CAS分别设置x和y坐标 public void set(int newX, int newY) { do { // 但这并不能保证x和y坐标更新的一致性 } while (!x.compareAndSet(x.get(), newX)); do { } while (!y.compareAndSet(y.get(), newY)); } }
上述代码中的set()方法尽管使用了CAS,但不能保证x和y坐标更新的一致性。如果需要同时更新x和y坐标,那么这种方法可能导致数据不一致。我们可以使用锁来保证多个变量的原子性更新,代码如下。
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; class Coordinates { private int x = 0; private int y = 0; private Lock lock = new ReentrantLock(); public void set(int newX, int newY) { lock.lock(); try { x = newX; y = newY; // 在锁的保护下同时更新x和y坐标,保证原子性 } finally { lock.unlock(); } } }
(5)伪共享(False Sharing)问题。
当多个线程对相互独立的变量进行CAS操作时,如果这些变量位于同一缓存行中,就可能因为缓存一致性协议导致性能下降。
解决方法:通过对齐和填充等技术,确保每个被频繁更新的变量都在自己独立的缓存行中,实现代码如下。
class PaddedCounter { // 假设缓存行是64字节,一个long类型变量是8字节 private volatile long p1, p2, p3, p4, p5, p6, p7 = 0; // 填充 private volatile long counter = 0; private long q1, q2, q3, q4, q5, q6, q7 = 0; // 填充 public void increment() { counter++; } }
在上面代码中,counter变量被其他变量(p1~p7和q1~q7)所包围,使用这些变量进行填充是为了确保counter独占一个缓存行。这样可以减少线程间因为更新临近变量而造成的缓存行无效化,从而减少缓存一致性协议带来的开销。注意,伪共享问题的解决方法依赖于具体的硬件架构,特别是缓存行的大小,所以在实际应用中需要根据目标机器的具体配置来设计。
在实现高效并发算法时,理解和解决这些问题是至关重要的。在某些情况下,可能需要考虑使用替代的同步机制(如锁或并发数据结构)来保证数据的一致性和系统的性能。