购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

1.1 面试官:谈谈你对线程和线程安全的理解

“谈谈你对线程和线程安全的理解”这个问题涉及的知识面较广,实际上,面试官是在通过这个问题考查求职者对线程及并发编程知识的掌握程度,具体考查点如下。

● 线程的创建:考查求职者对创建线程的不同方式以及它们之间的区别的了解程度,而创建线程的方式影响着程序的性能与复杂性。

● 线程生命周期:考查求职者是否了解线程的状态及状态的切换,这关系到对程序行为的控制和预测。

● 线程调度的策略:评估求职者对操作系统线程调度策略的理解程度,该策略直接关系到线程执行的有效性和效率。

● 并发编程:了解求职者是否知道为什么现代应用程序需要并发编程,以及是否能够识别并发带来的潜在问题。

● 并行与并发的区别:评估求职者对并行和并发概念的理解程度,以及他们在实际情况下对并行与并发的应用能力。

● 同步与阻塞的机制和关系:考查求职者是否能够正确实现线程间的同步,以及是否能够正确理解同步和阻塞的关系。

● 线程安全的实现:评估求职者在面对共享资源时,是否能够采取合适的措施来确保线程安全。

这些考查点对程序员来说具有重要意义,因为它们是构建高效、稳定、可扩展的多线程应用程序的基础。求职者应该做好充分的准备,确保自己能够清晰、准确地回答问题,展示自己的知识和技能。在回答相关问题时可以基于以下思路。

(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等。

为了让大家对线程和线程安全内容有更深入的掌握和理解,灵活应对面试细节,接下来我们对上述解答要点逐个进行详解。

1.1.1 Java创建和启动线程的方式有哪些?它们之间有什么区别?

在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接口和线程池来提供更高级的并发管理功能。

1.1.2 Java线程都有哪些状态?其状态是如何切换的?

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(); // 请求中断线程

以上是线程状态切换的常见路径,理解这些切换对于编写多线程程序是非常重要的。我们在编写多线程程序时,需要考虑线程同步、互斥锁、等待/通知机制等关键问题。

1.1.3 Java线程使用到了哪些调度策略?

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也运行在宿主操作系统之上,因此它也依赖于操作系统的线程调度策略。

1.1.4 为什么使用并发编程?需注意哪些问题?

并发编程是允许多个任务同时进行而不是顺序执行的一种编程技术。它涉及操作系统、编程语言、软件开发等多方面内容。并发编程的作用是让程序能够更有效地使用计算资源,特别是在多个CPU的系统上,它也用于处理同时发生的多个任务或请求。

假设我们正在为一家金融公司开发一个实时股票价格分析系统。该系统需要实时跟踪数百只股票的价格变动,并且对价格变化进行快速分析,从而为交易员提供买卖股票的决策支持。该系统的关键要求是低延迟,因为股市价格波动迅速,高延迟可能导致巨大的财务损失。

如果该系统串行处理每只股票的价格变动和分析,就会导致巨大的延迟,因为这样的系统会在处理完一只股票的所有价格变动和分析后才能开始处理下一只的。在高峰时段,价格变动的速度可能会超过系统处理的速度,导致数据堆积和过时。

如果采用并发编程,可以为每只或每组股票分配一个独立的处理线程或者使用事件驱动模型来处理股票的价格变动。每个线程可以独立地跟踪和分析一只或一组股票的价格变动,从而减少数据处理的延迟。使用并发队列来管理价格变动相关的事件,可以确保每只股票的价格变动都能够尽快地被处理。

使用并发编程有以下几个优点。

● 性能提升:并发编程可以显著提升应用程序在多个CPU上的性能,通过并行处理可以同时执行多个操作,相比串行处理能更快完成任务集合。

● 资源利用最大化:程序在并发执行时,可以更充分地利用CPU和其他资源,因为当一部分任务等待I/O(Input/Output,输入输出)操作或被阻塞时,其他任务可以继续进行计算。

● 吞吐量增加:对于服务端应用,使用并发编程能够同时处理多个客户端请求,从而增加应用程序的吞吐量。

● 响应性增强:在用户界面程序中,即使部分任务很耗时,通过并发编程也可以保持界面的响应性,因为耗时操作可以在独立的线程或过程中执行。

当然,除了优点以外,使用并发编程也存在以下几个缺点。

● 复杂性增加:并发代码通常比顺序执行的代码更复杂,需要更多的设计和调试时间,且难度更大。

● 存在同步问题:线程或进程间的同步(如互斥锁、信号量等)是并发编程中的一大挑战,不当的同步可能导致死锁。

● 调试困难:并发程序的调试通常比单线程程序的更加困难,因为问题可能只在特定的并发条件下才会发生,不可重现的问题更是常见。

● 性能开销较大:并发编程需要额外管理线程或进程的开销,如上下文切换和同步机制等,这些可能会抵消一些性能上的优势。

● 设计和测试的工作量较大:并发程序的设计和测试工作量通常要大于非并发程序的,因为需要考虑多种可能的执行顺序和交互情况。

使用并发编程可以提高程序的性能和响应率,充分利用计算机的多核处理能力。并发编程可以让程序在同时处理多个任务或请求时更加高效。

然而,并发编程面临着一些问题和挑战,使用并发编程需要注意以下几点。

● 同步机制:当多个线程同时访问和修改共享数据时,可能会导致数据不一致的问题。需要使用锁、原子性操作等机制来保证数据的一致性。

● 死锁:当多个线程持有资源并且互相等待其他线程释放资源时,可能会导致死锁。需要使用合适的资源分配和竞争避免策略避免死锁的发生。

● 上下文切换:当多个线程在同一个CPU上进行切换时,会消耗一定的时间和资源。需要合理控制线程的数量,避免过多的线程导致过多的上下文切换,影响性能。

● 线程间通信:线程或进程之间的通信通常需要特殊的同步机制,如信号量、锁、事件等。正确实现这些同步机制是确保数据一致性和程序正确性的关键。

● 并发安全性:需要保证程序在并发环境下的正确性和安全性。避免数据竞争、死锁和其他并发相关的问题。

● 性能优化:并发编程可能会带来一些性能问题,如线程间的争用、同步开销等。需要针对具体场景进行性能优化,提高并发程序的效率和吞吐量。

综上所述,虽然并发编程可以带来很多好处,但也需要注意解决并发相关的问题和挑战。合理的并发设计和编程技巧可以帮助我们充分发挥并发编程的优势,并确保程序的正确性和性能。我们需要深入理解并发模型,熟悉同步机制,并注意程序可能遭遇的并发相关问题和挑战。尽管存在问题和挑战,但在多个CPU日益普及的今天,适当地使用并发编程依然可以带来很多显著的好处。

1.1.5 并发编程和并行编程有什么区别?

并发(Concurrency)和并行(Parallelism)这两个概念在多任务处理领域经常被对比讨论。尽管这两个术语在日常用语中有时被交替使用,但在计算机科学中,它们有着明确且不同的含义。

(1)并发。

并发是指系统能够同时处理多个任务的能力。并发的重点在于任务的处理过程,而不是执行。并发涉及同时处理多个任务的能力,但这不一定意味着这些任务实际上是在同一时刻执行的。在单核CPU上,一个CPU可以通过任务间的快速切换,给用户一种多个任务同时执行的错觉。并发更多关注的是结构上的分解,即如何有效地组织程序以同时处理多个任务。

比如,操作系统中存在多任务处理,即使在只有一个CPU内核的计算机上,仍然可以同时浏览网页、播放音乐和编写文档。操作系统通过将CPU资源切片并分配给各个程序,使之能够并发运行,从宏观上看,这些程序似乎是在同时执行的,但是在CPU上它们实际上是串行执行的。

(2)并行。

并行是指多个CPU或计算机同时执行多个任务或工作负载的能力。并行的重点是性能,它通过同时执行多个操作,减少完成工作的总时间。并行需要使用多个CPU或计算机,其中每个CPU或计算机执行任务的不同部分。

例如,我们在使用多个CPU进行科学计算时,其中一个任务是计算一个大型数据集中所有元素的总和,那么这个任务可以被分割成更小的部分,每个部分分配给一个CPU,多个CPU同时计算。随后,所有CPU的计算结果被汇总以获得最终总和,这种方式显著减少了完成计算所需的总时间。

(3)并发编程和并行编程的区别。

了解并发和并行的概念后,我们应该知道,并发编程和并行编程也是多线程编程的两个概念,它们在Java中都有应用,但各自的侧重点和使用场景有所不同。

● 并发编程是关于如何利用有限的CPU资源高效地管理和调度多个任务,这些任务可能不会真正同时执行,但通过任务间的快速切换给用户以同时执行的错觉。

● 并行编程是关于如何将任务分配到多个CPU上,以便真正同时执行,从而提高程序的运行效率。

并发和并行都是现代计算中提高效率的关键概念,它们使得程序能够更加高效地利用资源。在多个CPU的环境下,并发和并行经常一起使用,以实现最大的效率和性能。在Java中,这两个概念也是交织在一起的,一个并发程序可以通过在多个CPU上并行执行多个线程来提高性能。然而,并发编程侧重于线程之间的协调和同步,而并行编程则侧重于线程的同时执行和性能提升。

1.1.6 什么是线程同步和阻塞?它们有什么关系?

在并发编程中,我们经常会遇到线程同步、异步、阻塞和非阻塞等概念,尤其是在涉及线程之间的协作和资源共享时。其实同步和异步是指线程执行方式,而阻塞和非阻塞是指线程执行状态。我们来详细介绍这些重要概念。

(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操作完成时也会发生阻塞,这与同步没有直接关系。

存在同步阻塞,也存在同步非阻塞,当然还存在异步阻塞和异步非阻塞。它们的作用都是保证多线程环境中程序的正确性和一致性,但如果不恰当地使用它们,可能会导致性能问题,如死锁或饥饿等。因此,在设计多线程程序时,需要仔细考虑线程之间的同步和阻塞策略。

1.1.7 什么是线程安全?如何确保线程安全?

线程安全是指多线程执行时,同一资源能够安全地被多个线程同时访问而不引发任何问题,如数据污染或不一致。一个线程安全的程序能够正确地处理并发请求,不论线程执行的顺序如何。

在实际开发中,线程安全非常重要,因为多个线程经常会同时访问共享数据或资源,如果没有采取适当的保护措施,就会导致数据不一致、错误或丢失等问题。

为了保证线程安全通常需要结合使用多种策略和技术,以下是一些保证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使用简单但可能会导致性能问题;而原子类适合计数器或状态标志;不可变对象完全避免了并发问题,但不适合所有场景。此外,我们在设计程序时应该遵循并发设计模式,比如单例模式、生产者-消费者模式、读写锁模式等。因此,设计线程安全的系统既是一种技术挑战,也是对设计能力的一个考验。 EbiUM6JzuGR6R0XYjLo/yJYaKvSjJ2saV7Dp2+qEKN+rWxHuCcAg36l6vwV0GqyX

点击中间区域
呼出菜单
上一章
目录
下一章
×