一文搞懂Java多线程,讲的很详细,收藏起来慢慢看

一、简介

什么是多线程

多线程是指在一个程序中同时执行多个线程,每个线程都有自己独立的执行路径。在多线程中,程序的执行可以同时进行多个任务,从而提高系统的资源利用率和响应性能。

在传统的单线程编程模型中,程序按照顺序执行,一次只处理一个任务。这种方式在某些情况下可能会导致效率低下或者无法满足需求。而多线程通过将任务拆分为多个子任务,并且在不同的线程上同时执行,从而实现并发处理。

多线程的作用和优势

  1. 提高系统的响应性能:多线程可以将长时间执行的任务放在后台线程中处理,使得主线程能够及时响应用户的操作。例如,在图形界面应用程序中,使用多线程可以将耗时的操作(如网络请求、文件读写等)放在后台线程中执行,保持界面的流畅和响应。

  2. 提高计算机资源的利用率:多线程可以同时利用多核处理器的优势,将任务分配到不同的线程上并行执行,提高计算机资源的利用率。这在数据密集型的计算任务中尤其有效,可以大大加快任务的完成速度。

  3. 实现任务的并行处理:对于可以并行执行的任务,多线程可以将任务分解为多个子任务,并通过多个线程同时执行,从而加快任务的完成速度。例如,在科学计算、图像处理等领域,多线程可以将问题划分为多个子问题,分配给不同的线程并行处理,提高处理效率。

  4. 异步编程:多线程可以实现异步编程模型,通过在后台线程执行耗时的操作,让主线程继续执行其他任务,提升用户体验。例如,在网络通信中,可以使用多线程实现异步请求和响应,避免阻塞主线程,提高系统的并发处理能力。

  5. 实现复杂的任务调度和协同:多线程可以用于实现复杂的任务调度和协同。不同的线程可以根据优先级或条件进行调度,完成不同的任务,实现复杂的业务逻辑。例如,在生产者-消费者模型中,可以使用多线程实现生产者线程和消费者线程之间的数据交换与同步。

进程与线程的关系

  1. 定义:

    • 进程(Process):是指一个程序在计算机上的执行实例。每个进程都拥有独立的内存空间、文件描述符、状态信息等。
    • 线程(Thread):是进程中的一个执行路径,也被称为轻量级进程。一个进程可以包含多个线程,线程共享所属进程的资源,如内存空间、文件句柄等。
  2. 关系:

    • 进程是操作系统进行资源分配和调度的基本单位,而线程是进程中独立执行的最小单位。一个进程可以拥有多个线程,这些线程共享同一进程的资源。
    • 在同一个进程中的多个线程之间可以通过共享内存来进行通信,相比于进程间通信(IPC),线程间通信更加高效和方便。
    • 线程之间的切换开销比进程之间的切换开销要小,因为线程共享进程的地址空间,切换时只需保存和恢复少量的寄存器状态即可。
    • 进程间是相互独立的,一个进程的崩溃不会影响其他进程;而线程之间是共享进程资源的,一个线程的错误可能会导致整个进程崩溃。
  3. 优势:

    • 进程间的切换开销较大,但在多核处理器上可以实现真正的并行处理。多个进程可以同时执行不同的任务,提高了系统的资源利用率。
    • 线程间的切换开销较小,而且可以实现更细粒度的并发控制。线程可以同时执行多个子任务,实现并发操作,提高了系统的响应性能。
  4. 应用:

    • 进程适用于需要隔离资源、保护数据完整性的场景。例如,操作系统中的各个进程相互隔离,互不干扰。
    • 线程适用于需要实现并发操作、提高计算机资源利用率的场景。例如,图形界面应用程序的界面响应和后台任务可以放在不同的线程中处理。

二、创建和管理线程

使用Thread类创建线程

  1. 创建子类: 首先,需要创建一个继承自Thread类的子类。可以通过定义一个类并让它继承Thread类来实现:

     java 

    复制代码

    public class MyThread extends Thread { // 线程执行的代码 @Override public void run() { // 线程要执行的任务 } }
  2. 重写run()方法: 在子类中,需要重写Thread类中的run()方法。run()方法是线程的入口点,会在线程启动时被调用,其中包含了线程要执行的任务逻辑。

  3. 创建线程对象: 在主线程或其他线程中,创建子类的对象作为线程对象:

     java 

    复制代码

    MyThread myThread = new MyThread();
  4. 启动线程: 调用线程对象的start()方法来启动线程,使其开始执行run()方法中定义的任务逻辑:

     java 

    复制代码

    myThread.start();

    注意:不能直接调用run()方法来启动线程,因为直接调用run()方法只会在当前线程中顺序执行,并不会创建新的线程。

  5. 线程执行结束: 当线程的run()方法执行完毕或者线程被中断时,线程将自动结束。也可以在run()方法中使用return语句显式返回,来提前结束线程的执行。

需要注意的是,通过继承Thread类创建线程的方法存在一些限制和不足:

  • 由于Java是单继承的语言,因此一个类只能继承一个父类,如果已经继承了其他类,则无法再使用继承Thread类的方式创建线程。
  • 继承Thread类会导致子类与Thread类高度耦合,违反了面向对象的设计原则。
  • Thread类本身提供了许多方法和属性(如start、run、stop等),如果继承Thread类,子类将无法重写这些方法或隐藏这些属性。

为了克服以上限制和不足,更常用的创建线程的方式是实现Runnable接口,并将其作为参数传递给Thread类的构造方法。这种方式可以更好地利用Java的接口特性,提高代码的灵活性和可复用性。

使用Runnable接口创建线程

  1. 创建实现类: 首先,需要创建一个实现了Runnable接口的类。可以定义一个类,并让它实现Runnable接口:

     java 

    复制代码

    public class MyRunnable implements Runnable { // 线程执行的代码 @Override public void run() { // 线程要执行的任务 } }
  2. 实现run()方法: 在实现类中,需要实现Runnable接口中的run()方法。run()方法是线程的入口点,包含了线程要执行的任务逻辑。

  3. 创建线程对象: 在主线程或其他线程中,创建实现类的对象作为Runnable对象:

     java 

    复制代码

    MyRunnable myRunnable = new MyRunnable();
  4. 创建线程实例: 将Runnable对象作为参数传递给Thread类的构造方法,创建Thread类的实例:

     java 

    复制代码

    Thread myThread = new Thread(myRunnable);
  5. 启动线程: 调用线程对象的start()方法来启动线程,使其开始执行run()方法中定义的任务逻辑:

     java 

    复制代码

    myThread.start();

    注意:不能直接调用run()方法来启动线程,因为直接调用run()方法只会在当前线程中顺序执行,并不会创建新的线程。

  6. 线程执行结束: 当线程的run()方法执行完毕或者线程被中断时,线程将自动结束。也可以在run()方法中使用return语句显式返回,来提前结束线程的执行。

使用Runnable接口创建线程相比继承Thread类有以下优势:

  • Java语言支持多实现,一个类可以实现多个接口,因此可以更灵活地创建线程。
  • 通过实现Runnable接口,可以将任务逻辑与线程的启动和管理逻辑分离,使代码更清晰、结构更合理。
  • Runnable对象可以作为参数传递给其他线程或线程池,实现更高级的线程管理和复用。

线程的生命周期

  1. 新建状态(New): 当线程对象被创建但还没有调用start()方法时,线程处于新建状态。此时该线程的资源已被分配,但尚未启动。

  2. 就绪状态(Runnable): 当线程调用了start()方法后,线程进入就绪状态。此时线程已经准备好执行,但还没有分配到CPU时间片。在就绪状态中,线程将等待获取CPU的执行权。

  3. 运行状态(Running): 线程获得了CPU的执行权,进入运行状态。此时线程的run()方法会被调用,并且线程开始执行任务逻辑。在运行状态中,线程会一直执行,直到主动让出CPU资源或被其他高优先级线程抢占CPU。

  4. 阻塞状态(Blocked): 在某些情况下,线程可能会由于某种原因而暂停执行,此时线程进入阻塞状态。例如,线程在等待某个锁的释放、等待IO操作完成、等待其他线程的通知等情况下会进入阻塞状态。当满足某些特定条件后,线程可以从阻塞状态转变为就绪状态,等待获取CPU的执行权。

  5. 等待状态(Waiting): 当线程等待某个特定条件的时候,它可能会进入等待状态。例如,线程调用了wait()方法或join()方法后会进入等待状态。只有当满足一定条件时,等待的线程才能继续执行。

  6. 超时等待状态(Timed Waiting): 在特定的时间范围内等待某个条件满足,线程处于超时等待状态。例如,线程调用了sleep()方法或带有超时参数的方法,线程会暂时停止执行,并在指定的时间后自动进入就绪状态。

  7. 终止状态(Terminated): 当线程的run()方法执行完毕或异常终止时,线程进入终止状态。此时线程已经结束执行,并释放了占用的资源。

需要注意的是,线程的状态可能会在不同的操作和条件下转变。例如,一个新建状态的线程可能直接进入阻塞状态,一个就绪状态的线程可能被调度器选择执行,运行状态的线程可能因某些原因而进入阻塞状态等等。

三、线程同步与互斥

同步的概念

在多线程编程中,同步是指协调多个线程之间的执行顺序,以确保它们按照预期的顺序和方式访问共享资源。同步机制可以防止多个线程同时修改共享数据导致的数据不一致、竞态条件等问题。

  1. 共享资源: 共享资源是多个线程共同访问的资源,例如内存中的变量、数据库、文件等。多个线程对共享资源的并发访问可能会引发数据不一致或其他问题。

  2. 临界区: 临界区是指访问共享资源的代码块或方法,只允许一个线程在同一时间内执行临界区的代码。为了保证在线程访问共享资源时的互斥性,在进入临界区之前需要获取某种同步机制(如锁)。

  3. 同步机制: 同步机制用于协调线程对共享资源的访问,确保线程按照特定的顺序执行,避免竞态条件和数据不一致的问题。常见的同步机制包括临界区、锁、互斥量、信号量、条件变量等。

  4. 锁(Lock): 锁是一种最基本的同步机制,用于保护临界区,防止多个线程同时访问共享资源。在进入临界区之前,线程需要获取锁,在退出临界区时释放锁。常见的锁包括互斥锁(Mutex)和可重入锁(Reentrant Lock)。

  5. 互斥性: 互斥性是同步机制的核心概念,指同一时间只有一个线程能够持有某个特定的锁,其他线程需要等待该锁的释放才能获取它。

  6. 条件变量(Condition): 条件变量用于线程间的通信和协调,使得线程可以按照特定的条件进行等待或唤醒。条件变量通常与锁结合使用,通过等待和唤醒操作来实现线程之间的同步。

  7. 死锁(Deadlock): 死锁是多个线程在争夺资源时可能出现的一种问题,导致所有线程都无法继续执行。发生死锁时,每个线程都在等待其他线程释放资源,从而形成循环等待的局面。

同步机制的目标是确保线程安全,即在多线程环境中保证共享资源的正确性和一致性。通过合适的同步机制和良好的并发编程实践,可以防止数据竞争和线程间的冲突,保证程序的正确运行。

synchronized关键字

synchronized 是 Java 中用于实现线程同步的关键字,它可以应用于方法或代码块。使用 synchronized 关键字可以确保在同一时间内只有一个线程可以执行被保护的代码,从而解决多线程访问共享资源时可能出现的竞态条件和数据不一致问题。

  1. 同步方法: 在方法声明中使用 synchronized 关键字,该方法称为同步方法。当一个线程调用同步方法时,它将锁定该方法所属对象(即该方法的调用者),其他线程将无法同时调用该方法,直到该方法执行完毕或抛出异常释放锁。

    示例:

     java 

    复制代码

    public synchronized void synchronizedMethod() { // 同步方法的代码块 }
  2. 同步代码块: 使用 synchronized 关键字来修饰一段代码块,该代码块称为同步代码块。只有获取了同步代码块所属对象的锁的线程才能进入该代码块执行,其他线程需要等待锁的释放才能执行该代码块。

    示例:

     java 

    复制代码

    public void someMethod() { synchronized (lockObject) { // 同步代码块 } }
  3. 对象锁和类锁: 当 synchronized 修饰实例方法或代码块时,它获取的是该方法所属对象的锁,也称为对象锁。当 synchronized 修饰静态方法或代码块时,它获取的是该方法所属类的锁,也称为类锁。

  4. 锁的互斥性: synchronized 保证了线程对同步方法或同步代码块的互斥访问,即同一时间只有一个线程可以获得锁并执行同步代码,其他线程需要等待。

  5. 锁的可重入性: 同一个线程在获取锁之后,可以再次获取它。这种机制称为锁的可重入性,确保一个线程在执行同步代码时,不会因为自身已经持有锁而被阻塞。

  6. 内置锁: Java 中的每个对象都与一个内置的锁(也称为监视器锁)相关联,使用 synchronized 关键字时,它会隐式地获取和释放这个内置锁。只有获取了对象的内置锁的线程才能执行同步方法或同步代码块。

  7. 线程安全性: 使用 synchronized 关键字可以保证共享资源在多线程环境下的线程安全性,避免数据竞争和数据不一致的问题。

需要注意的是,synchronized 关键字虽然简单易用,但它的粒度比较大,可能会导致线程的竞争和等待时间过长。在性能要求较高的情况下,可以考虑使用更细粒度的锁(如 ReentrantLock)或其他并发工具来实现线程同步。

volatile关键字的作用

volatile 是 Java 中的一个关键字,用于修饰变量。使用 volatile 关键字可以确保该变量在多线程环境下的可见性和禁止指令重排序,从而解决了多线程并发访问时可能出现的线程间数据不一致的问题。

  1. 可见性: 当一个变量被声明为 volatile 时,它的值在多个线程之间是可见的。也就是说,当一个线程修改了被 volatile 修饰的变量的值时,其他线程能够立即看到这个改变,而不是使用自己的缓存值。

  2. 禁止指令重排序: 编译器和处理器为了提高执行效率,可能会对指令进行重排序。在使用 volatile 关键字修饰的变量上进行读写操作时,不会发生指令重排序,从而保证了线程间操作顺序的一致性。

  3. 适用场景: a. 对变量的写入操作不依赖于当前值,或者只有单个线程修改变量的值。 b. 对变量的读取操作不依赖于其他变量的状态。

需要注意的是,volatile 关键字无法保证原子性,即不能代替锁来实现复杂的同步需求。如果需要保证原子性操作,例如 i++ 操作,仍然需要使用 synchronized 关键字或原子类(如 AtomicInteger)来实现。

示例用法:

 
 

java

复制代码

public class VolatileExample { private volatile boolean flag = false; public void setFlag() { flag = true; } public void doSomething() { while (!flag) { // 等待 flag 变为 true } // 执行其他操作 } }

在上述示例中,通过将 flag 变量声明为 volatile,线程在执行 doSomething 方法时会不断检查 flag 的值。当另一个线程调用 setFlag 方法将 flag 设置为 true 时,doSomething 方法会立即检测到 flag 的改变并继续执行。

线程间的通信

线程间的通信是指多个线程之间交换信息或共享数据的过程。在多线程编程中,线程间的通信主要有两种方式:共享内存和消息传递。

  1. 共享内存: 使用共享内存的方式,线程之间通过访问相同的共享变量来进行通信。共享变量可以是全局变量、实例变量或静态变量等。线程通过对共享变量的读写操作来实现数据的交换和共享。

    示例:

     java 

    复制代码

    // 共享变量 public class SharedData { public static int count = 0; } // 线程 A public class ThreadA extends Thread { public void run() { SharedData.count += 1; } } // 线程 B public class ThreadB extends Thread { public void run() { int currentCount = SharedData.count; // 使用 currentCount 进行其他操作 } }

    在上述示例中,线程 A 对共享变量 count 进行加法操作,而线程 B 则读取 count 的当前值并进行其他操作。通过共享变量 count,线程 A 和线程 B 实现了数据的交换和共享。

    需要注意的是,使用共享内存方式进行线程间通信时,需要考虑线程安全问题,例如使用 synchronized 关键字或锁来保证数据的一致性。

  2. 消息传递: 使用消息传递的方式,线程之间通过发送和接收消息来进行通信。每个线程都有自己的消息队列,线程可以将消息发送到其他线程的消息队列中,其他线程再从队列中取出消息进行处理。

    示例:

     java 

    复制代码

    // 消息队列 public class MessageQueue { private Queue<String> queue = new LinkedList<>(); public synchronized void sendMessage(String message) { queue.add(message); notify(); // 唤醒等待的线程 } public synchronized String receiveMessage() throws InterruptedException { while (queue.isEmpty()) { wait(); // 等待消息到达 } return queue.poll(); } } // 发送消息的线程 public class SenderThread extends Thread { private MessageQueue messageQueue; public SenderThread(MessageQueue messageQueue) { this.messageQueue = messageQueue; } public void run() { // 发送消息 messageQueue.sendMessage("Hello!"); } } // 接收消息的线程 public class ReceiverThread extends Thread { private MessageQueue messageQueue; public ReceiverThread(MessageQueue messageQueue) { this.messageQueue = messageQueue; } public void run() { try { // 接收消息 String message = messageQueue.receiveMessage(); // 处理消息 } catch (InterruptedException e) { e.printStackTrace(); } } }

    在上述示例中,通过 MessageQueue 类实现了一个简单的消息队列。SenderThread 线程向消息队列发送消息,ReceiverThread 线程则从消息队列接收消息进行处理。通过消息的发送和接收,线程之间实现了信息的传递和通信。

    使用消息传递方式进行线程间通信时,可以避免共享变量导致的一些线程安全问题。但需要注意同步机制,例如在消息队列中使用 wait() 和 notify() 方法来实现等待和唤醒操作。

四、线程调度和控制

线程优先级

线程优先级是指操作系统对不同线程调度执行的相对优先顺序的一种设置。通过设置线程的优先级,可以影响线程在竞争CPU资源时的调度顺序,从而实现对线程执行的控制。

在Java中,线程优先级使用一个整数表示,范围从1到10,其中1是最低优先级,10是最高优先级。默认情况下,所有线程的优先级都为5(NORM_PRIORITY)。可以使用Thread类的setPriority()方法设置线程的优先级,使用getPriority()方法获取线程的优先级。

线程的优先级设置并不能保证绝对精确的执行顺序,只是给操作系统一个提示,让其在调度时更有可能先执行优先级较高的线程。操作系统的调度策略和实现方式可能会有所差异,因此在不同操作系统上,线程优先级的行为和效果可能会有所不同。

需要注意的是,线程优先级的设置可能会受到操作系统的限制,例如某些操作系统可能只支持较少的优先级级别,或者将所有线程都视为相同优先级。此外,通过设置线程优先级来实现程序的正确性通常并不是一个好的做法,应该尽量避免过于依赖线程优先级来进行程序设计。

尽管线程优先级的效果可能会因操作系统和硬件平台而异,但在某些情况下,合理设置线程优先级仍然是有意义的。例如,当某个线程需要更多的CPU时间来执行关键任务时,可以将其优先级设置为较高,以提高其执行的机会。

然而,应该谨慎使用线程优先级,并且尽量避免过度依赖线程优先级的正确性。在编写多线程程序时,应该尽可能设计出不依赖于线程优先级的算法和逻辑,以保证程序的可移植性和可靠性。

守护线程

守护线程(Daemon Thread)是在程序运行过程中在后台提供一种支持性工作的线程。与普通线程相比,守护线程具有较低的优先级,当所有非守护线程都结束时,守护线程会随之自动终止。

守护线程的主要特点如下:

  1. 后台运行:守护线程在程序运行期间在后台默默地执行,不会阻止程序的终止。只要没有非守护线程在运行,JVM就会退出并终止守护线程。

  2. 低优先级:守护线程通常拥有较低的优先级,这意味着当有多个线程竞争CPU资源时,非守护线程更容易被调度执行。

  3. 支持性工作:守护线程主要用于在后台执行支持性工作,例如垃圾回收机制(GC)、JIT 编译器的优化、定期任务的执行等。它们为其他线程提供服务和支持,但并不直接参与核心业务逻辑的处理。

在Java中,可以通过Thread类的setDaemon()方法设置线程是否为守护线程。守护线程需要在start()方法调用之前设置,否则会抛出IllegalThreadStateException异常。

示例:

 
 

java

复制代码

Thread daemonThread = new Thread(new Runnable() { public void run() { // 执行守护线程的相关工作 } }); daemonThread.setDaemon(true); // 设置为守护线程 daemonThread.start();

需要注意的是,守护线程并不能无限制地执行,一旦所有非守护线程都结束了,JVM 就会终止守护线程。因此,在设计守护线程时必须要确保不会导致程序逻辑的异常或数据丢失。

守护线程的典型应用场景包括定时任务的后台执行、日志记录、服务线程的监控等。通过合理利用守护线程,可以提高程序的整体性能和效率。但同时也需要注意避免在守护线程中进行关键性的业务逻辑处理,以免造成数据不一致或其他问题。

线程组

线程组(Thread Group)是Java中用于管理和组织线程的机制。它提供了一种将线程组织在一起,并对其进行统一控制和操作的方式。通过线程组,可以对一组线程进行统一的管理、设置属性、优先级调整、异常处理等。

线程组的主要特点如下:

  1. 分层结构:线程组可以形成一个分层的树状结构。每个线程组都可以包含多个线程或其他线程组,从而形成嵌套的层次结构。这样可以更加方便地对线程进行管理和组织。

  2. 统一属性设置:线程组允许设置一些公共属性,例如线程组的名称、父线程组、守护状态等。通过设置线程组的属性,可以一次性地对组内的所有线程进行属性的统一配置。

  3. 统一控制和操作:线程组允许对组内的所有线程进行统一的控制和操作。例如,可以通过线程组一次性地设置和修改所有线程的优先级、中断所有线程、暂停/恢复所有线程等。

  4. 异常处理:线程组可以设置一个未捕获异常处理器(UncaughtExceptionHandler),用于处理组内线程抛出的未捕获异常。当线程抛出异常时,如果未设置线程的专用异常处理器,则会将异常传递给线程所在的线程组进行处理。

使用线程组的示例代码如下:

 
 

java

复制代码

ThreadGroup group = new ThreadGroup("MyThreadGroup"); // 创建线程组 Thread thread1 = new Thread(group, new Runnable() { public void run() { // 线程1的任务逻辑 } }); Thread thread2 = new Thread(group, new Runnable() { public void run() { // 线程2的任务逻辑 } }); // 设置线程组属性 group.setMaxPriority(Thread.MAX_PRIORITY); // 设置最高优先级 group.setDaemon(true); // 设置为守护线程组 // 启动线程 thread1.start(); thread2.start(); // 统一操作线程组内的所有线程 group.interrupt(); // 中断组内的所有线程

需要注意的是,线程组并不是一种强制性的机制,它并不影响线程的执行和调度。线程组的主要作用是提供了一种组织和管理线程的方式,方便对线程进行集中控制和操作。在实际应用中,由于线程组的功能和限制较少,使用线程组还需谨慎权衡,根据实际需求选择是否使用。

线程池的概念和使用

线程池(Thread Pool)是一种通过预先创建并管理一组线程,以便在需要时重用这些线程来执行任务的机制。线程池的核心思想是将任务与线程的创建和销毁分离,从而减少线程创建和销毁的开销,并提高线程的重用性和性能。

线程池的主要特点如下:

  1. 线程复用:线程池中的线程可以被多个任务重复使用,避免了频繁创建和销毁线程的开销,提高了系统的性能和响应速度。

  2. 线程管理:线程池对线程进行统一的管理,包括线程的创建、销毁、调度等。通过线程池,可以有效控制同时运行的线程数量,防止系统资源被过多的线程占用。

  3. 任务队列:线程池通常配合任务队列使用,将需要执行的任务提交到队列中,线程池会从队列中取出任务,并分配线程来执行。

  4. 线程调度:线程池通过内部的调度算法,根据任务的优先级和其他策略来决定哪个任务先执行,哪个任务后执行。

  5. 线程管理和监控:线程池提供了一些方法来管理线程池,如动态调整线程池大小、查看线程池状态等。还可以通过监控线程池的运行情况,了解线程池的负载状况和任务执行情况。

使用线程池的示例代码如下:

 
 

java

复制代码

ExecutorService executor = Executors.newFixedThreadPool(5); // 创建固定大小的线程池,最多同时执行5个线程 // 提交任务给线程池执行 executor.submit(new Runnable() { public void run() { // 任务逻辑 } }); // 关闭线程池 executor.shutdown();

通过Executors类提供的工厂方法可以创建不同类型的线程池,例如:

  • newFixedThreadPool(int nThreads):创建固定大小的线程池,线程数固定为nThreads
  • newCachedThreadPool():创建可缓存的线程池,根据需要创建新线程,空闲线程会被重用。
  • newSingleThreadExecutor():创建单个线程的线程池,保证所有任务按顺序执行。
  • newScheduledThreadPool(int corePoolSize):创建固定大小的定时任务线程池,可以执行定时和周期性的任务。

在使用线程池时,需要注意以下几点:

  • 合理设置线程池大小,避免过多的线程占用系统资源,或者线程不足导致任务处理不及时。
  • 适当选择合适的任务队列,根据任务的特性来选择合适的队列类型,例如ArrayBlockingQueueLinkedBlockingQueue等。
  • 及时关闭线程池,避免资源泄漏和线程长时间闲置。
  • 注意处理任务中可能发生的异常,防止异常导致线程池的假死或崩溃。

线程池是多线程编程中常用的一种设计模式,可以提高线程的复用性和效率,并且能更好地管理和调度线程。在实际开发中,合理使用线程池可以避免线程创建和销毁的开销,从而提升系统的性能和响应速度。

五、线程安全和共享资源

共享资源的概念

共享资源是指在多个并发执行的线程或进程之间共同使用的数据或对象。它可以是内存中的变量、文件、数据库连接、网络连接等。多个线程或进程可以同时访问、读取或修改这些共享资源。

共享资源的概念在并发编程中非常重要,因为多个线程或进程同时对共享资源进行操作时,可能会引发一些并发访问的问题,如竞态条件(Race Condition)、死锁(Deadlock)、数据不一致等。

常见的共享资源包括以下几种:

  1. 共享变量:在多线程编程中,多个线程可以同时访问和修改同一个变量。如果这个变量没有采取适当的同步机制,可能导致数据不一致的问题。

  2. 文件:多个进程或线程可以同时读取或写入同一个文件。在并发访问文件时,需要考虑文件描述符的竞争、写入时的覆盖问题以及文件读写操作的顺序等。

  3. 数据库连接:多个进程或线程可以通过数据库连接同时对数据库进行访问。在并发访问数据库时,需要考虑事务的隔离性、并发控制、死锁等问题。

  4. 网络连接:多个进程或线程可以通过网络连接进行通信。在并发网络通信中,需要处理并发请求、数据交错、数据包丢失等问题。

对于共享资源,需要采取合适的同步机制来保证多线程或多进程之间的安全访问。常用的同步机制包括:

  1. 互斥锁(Mutex):通过互斥锁可以实现对共享资源的互斥访问,同一时间只允许一个线程或进程访问共享资源。

  2. 信号量(Semaphore):通过信号量可以控制对共享资源的并发访问数量,可以设置许可证的数量来限制同时访问的线程或进程个数。

  3. 条件变量(Condition):条件变量用于线程间的通信和同步,可以在某个条件满足时唤醒等待的线程。

  4. 原子操作(Atomic Operation):原子操作是不可被中断的操作,可以保证多线程环境下对共享资源的原子性操作。

在使用共享资源时,需要考虑以下几点:

  • 确定哪些数据或对象是共享资源,需要在多个线程或进程之间共享。
  • 合理选择适当的同步机制来保证共享资源的安全访问,避免竞态条件等并发问题。
  • 避免过度使用共享资源,尽量减少线程或进程之间对共享资源的依赖,以降低并发访问带来的复杂性和开销。
  • 注意资源的释放,避免资源泄漏问题。

线程安全性问题

线程安全性问题是指在多线程环境下,当多个线程同时访问共享资源时可能出现的数据不一致或程序错误的情况。线程安全性是一个并发编程中非常重要的概念,解决线程安全性问题是确保多线程程序正确运行的关键。

以下是几种常见的线程安全性问题:

  1. 竞态条件(Race Condition):竞态条件是指多个线程同时访问共享资源,并且执行的顺序不确定,最终的结果取决于线程调度的具体情况。如果对共享资源的读写操作没有合适的同步机制,可能会导致数据不一致或错误的结果。

  2. 数据竞争(Data Race):数据竞争是竞态条件的一种特殊情况,指两个或多个线程同时对同一个共享变量进行读写操作,且至少有一个是写操作。数据竞争可能会导致未定义的行为或产生不可预测的结果。

  3. 死锁(Deadlock):死锁是指两个或多个线程相互等待对方释放资源而无法继续执行的状态。当每个线程都持有一些资源,并且等待其他线程释放它们所需要的资源时,可能会发生死锁情况。解决死锁问题通常需要合理地设计和管理资源的获取和释放顺序。

  4. 活锁(Livelock):活锁是一种特殊的死锁情况,线程不断地改变自己的状态以避免死锁,但最终无法取得进展。在活锁中,线程可能持续重试某个操作,并且在重试过程中无法执行其他有用的工作。

  5. 饥饿(Starvation):饥饿是指一个或多个线程由于优先级较低或其他原因,无法获得所需的资源而无法继续执行。当某些线程长时间无法获得资源时,会导致饥饿问题。

为了解决线程安全性问题,可以采用以下策略:

  1. 使用同步机制:如互斥锁、信号量、条件变量等来保证多线程对共享资源的互斥访问。

  2. 原子操作:使用原子操作来保证多线程对共享变量的原子性操作,避免数据竞争。

  3. 并发数据结构:使用线程安全的并发数据结构,如线程安全的队列、哈希表等,来避免手动同步的复杂性。

  4. 避免共享数据:尽量避免多个线程之间共享数据的情况,通过线程间消息传递等方式实现通信。

  5. 合理设计资源获取和释放:避免死锁情况的发生,考虑资源分配的顺序、避免循环等待等。

示例

以下是几个常见的线程安全性策略的 Java 示例代码:

  1. 使用 synchronized 关键字实现同步:
 
 

java

复制代码

public class ThreadSafetyExample { // 共享资源 private int sharedResource = 0; // 同步方法 private synchronized void increment() { for (int i = 0; i < 100000; i++) { sharedResource++; } } public static void main(String[] args) throws InterruptedException { // 创建线程安全示例对象 ThreadSafetyExample example = new ThreadSafetyExample(); // 创建并启动多个线程 List<Thread> threads = new ArrayList<>(); for (int i = 0; i < 5; i++) { Thread t = new Thread(example::increment); threads.add(t); t.start(); } // 等待所有线程执行完成 for (Thread t : threads) { t.join(); } System.out.println("Result: " + example.sharedResource); } }

  1. 使用 Atomic 原子类保证数据的原子性访问:
 
 

java

复制代码

import java.util.concurrent.atomic.AtomicInteger; public class ThreadSafetyExample { // 共享变量 private AtomicInteger sharedVariable = new AtomicInteger(0); // 线程函数 private void increment() { for (int i = 0; i < 100000; i++) { sharedVariable.incrementAndGet(); } } public static void main(String[] args) throws InterruptedException { // 创建线程安全示例对象 ThreadSafetyExample example = new ThreadSafetyExample(); // 创建并启动多个线程 List<Thread> threads = new ArrayList<>(); for (int i = 0; i < 5; i++) { Thread t = new Thread(example::increment); threads.add(t); t.start(); } // 等待所有线程执行完成 for (Thread t : threads) { t.join(); } System.out.println("Result: " + example.sharedVariable.get()); } }

  1. 使用线程安全的队列实现线程间通信:
 
 

java

复制代码

import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; public class ThreadSafetyExample { // 线程安全的队列 private BlockingQueue<Integer> queue = new LinkedBlockingQueue<>(); // 生产者线程函数 private void producer() { try { for (int i = 0; i < 5; i++) { queue.put(i); } } catch (InterruptedException e) { e.printStackTrace(); } } // 消费者线程函数 private void consumer() { try { while (true) { int item = queue.take(); if (item == -1) { break; } System.out.println("Consumed: " + item); } } catch (InterruptedException e) { e.printStackTrace(); } } public static void main(String[] args) throws InterruptedException { // 创建线程安全示例对象 ThreadSafetyExample example = new ThreadSafetyExample(); // 创建并启动生产者和消费者线程 Thread producerThread = new Thread(example::producer); Thread consumerThread = new Thread(example::consumer); producerThread.start(); consumerThread.start(); // 等待生产者线程执行完成 producerThread.join(); // 通知消费者线程结束 example.queue.put(-1); consumerThread.join(); } }

volatile、synchronized和Lock的比较

volatile、synchronized和Lock是Java中用于处理线程安全的三种机制。它们在实现线程安全方面有所不同。

  1. volatile关键字:
  • 使用范围:可以修饰变量。
  • 作用:保证被修饰的变量对所有线程的可见性,禁止指令重排序。
  • 原子性:不具备原子性,无法保证复合操作的原子性。
  • 适用场景:适用于简单的变量读写操作,如标志位、状态判断等。
  1. synchronized关键字:
  • 使用范围:可以修饰方法、代码块。
  • 作用:提供对共享资源的互斥访问,保证了原子性、可见性和有序性。
  • 原子性:具备原子性,每次只能有一个线程获得锁,其他线程等待。
  • 适用场景:适用于对共享资源进行复杂操作的情况,但对性能有一定影响。
  1. Lock接口:
  • 使用范围:需要通过Lock接口的实现类来使用,如ReentrantLock。
  • 作用:提供对共享资源的互斥访问,相比synchronized更加灵活,可以实现更多高级功能(如可重入锁、条件变量等)。
  • 原子性:具备原子性,每次只能有一个线程获得锁,其他线程等待。
  • 适用场景:适用于对共享资源进行复杂操作、需要更多灵活性和扩展性的情况。

比较总结:

  • volatile关键字适用于简单的变量读写操作,对于复合操作不具备原子性,主要用于保证变量的可见性和禁止指令重排序。
  • synchronized关键字适用于简单到复杂的共享资源访问,提供了对共享资源的互斥访问,保证了原子性、可见性和有序性,但对性能有一定影响。
  • Lock接口适用于复杂的共享资源访问,相比synchronized更加灵活,可以实现更多高级功能,如可重入锁、条件变量等,但使用相对复杂。

使用并发集合类

Java提供了一些并发集合类,这些类是线程安全的,适用于多线程环境下的数据共享。下面详细介绍几个常用的并发集合类:

  1. ConcurrentHashMap: ConcurrentHashMap 是线程安全的哈希表实现。与传统的 HashMap 不同,ConcurrentHashMap 使用了分段锁,在大部分操作上可以支持并发访问。它允许多个线程同时读取,而写操作只会锁住特定的段,不会对其他部分产生影响,从而提高了并发性能。

  2. CopyOnWriteArrayList: CopyOnWriteArrayList 是线程安全的 ArrayList 实现。它通过在每次修改操作时创建一个新的底层数组来保证线程安全,从而实现了读写分离,读操作不需要加锁。这意味着多个线程可以同时读取并发访问列表,而无需互斥。

  3. BlockingQueue: BlockingQueue 是一个阻塞队列接口,它提供了线程安全的入队和出队操作。它提供了多种实现类,如 ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue 等。这些实现类提供了不同的特性,如有界或无界、先进先出或优先级顺序等。BlockingQueue 的主要特点是在队列为空时获取元素会被阻塞,当队列已满时插入元素会被阻塞,适用于生产者-消费者模型。

  4. ConcurrentLinkedQueue: ConcurrentLinkedQueue 是一个非阻塞的线程安全队列实现。它采用无锁算法(CAS)来实现并发访问,不需要像阻塞队列一样使用锁来保证线程安全。它适用于高并发环境下的队列操作。

  5. BlockingDeque: BlockingDeque 是一个阻塞双端队列接口,它继承了 BlockingQueue 接口,并提供了在两端插入和移除元素的操作。它提供了几个实现类,如 LinkedBlockingDeque 和 ArrayBlockingDeque,可以根据需求选择适合的实现类。

六、并发编程实践

线程安全的设计与实现

  1. 互斥同步(Mutex Synchronization):

    • 使用synchronized关键字:可以修饰方法或代码块,保证同一时间只有一个线程可以执行被标记的代码。synchronized关键字会自动加锁和释放锁,确保线程安全。
    • 使用ReentrantLock类:该类提供了显示的加锁和解锁操作,使用起来更加灵活。可以通过lock()方法获取锁,通过unlock()方法释放锁。
  2. 原子性(Atomicity):

    • 使用Atomic包中的原子类:例如AtomicInteger、AtomicLong等,它们使用了底层的CAS(Compare And Swap)操作,保证特定操作的原子性。
  3. 可见性(Visibility):

    • 使用volatile关键字:可以确保被标记变量的读取和写入对其他线程是可见的。当一个线程写入volatile变量时,会立即刷新缓存并通知其他线程读取最新值。
    • 使用synchronized或Lock机制:在一对锁之间的所有操作都是可见的。
  4. 有序性(Ordering):

    • 使用volatile关键字:由于volatile关键字确保了可见性,它还提供了一定程度的有序性保证。通过对volatile变量的读取和写入,可以保证发生在volatile写之前的操作不会被重排到其后。
  5. 并发集合类:

    • Java提供了许多线程安全的并发集合类,如ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue等。它们内部使用锁或无锁算法来保证线程安全。
  6. 线程局部变量(Thread-local variables):

    • 使用ThreadLocal类可以为每个线程提供独立的变量副本,从而避免了线程安全问题。
  7. 不可变对象(Immutable Objects):

    • 创建不可变对象,保证对象的状态不可更改。由于不可变对象是线程安全的,所以多个线程可以共享它们而无需额外的同步。
  8. 避免共享数据:

    • 尽量避免多个线程修改同一份数据,而是让每个线程拥有自己的数据副本,从而减少线程间的竞争。

死锁和解决方案

死锁(Deadlock)是指两个或多个线程互相持有对方所需的资源而无法继续执行的情况,从而导致程序无法正常结束。

  1. 死锁的原因:

    • 互斥条件(Mutual Exclusion):资源一次只能被一个线程使用。
    • 请求与保持条件(Hold and Wait):线程已经持有了一个资源,但又在等待获取其他资源。
    • 不可剥夺条件(No Preemption):线程已经获得的资源不能被其他线程抢占。
    • 循环等待条件(Circular Wait):存在一个线程资源的循环链。
  2. 死锁的示例:

     java 

    复制代码

    public class DeadlockExample { private static Object lock1 = new Object(); private static Object lock2 = new Object(); public static void main(String[] args) { Thread thread1 = new Thread(() -> { synchronized (lock1) { System.out.println("Thread 1 acquired lock1"); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (lock2) { System.out.println("Thread 1 acquired lock2"); } } }); Thread thread2 = new Thread(() -> { synchronized (lock2) { System.out.println("Thread 2 acquired lock2"); synchronized (lock1) { System.out.println("Thread 2 acquired lock1"); } } }); thread1.start(); thread2.start(); } }

    上述示例中,thread1线程获取lock1后,尝试获取lock2,而此时thread2线程已经获取了lock2,尝试获取lock1,因此两个线程互相等待对方释放资源,导致死锁。

  3. 死锁的解决方案:

    • 避免策略:
      • 破坏互斥条件:例如使用可重入锁取代互斥锁。
      • 破坏请求与保持条件:要求线程一次性申请所有需要的资源,或者通过资源预先分配来避免线程等待。
      • 破坏不可剥夺条件:允许线程抢占资源。
      • 破坏循环等待条件:通过按照固定顺序获取资源,或者使用资源分级,以避免出现循环等待的情况。
    • 检测与恢复策略:
      • 死锁检测:通过算法检测系统是否发生死锁,并采取相应措施解除死锁。
      • 死锁恢复:通过终止一个或多个死锁线程,释放资源,从而恢复程序正常执行。
    • 防止死锁发生:
      • 获取锁的顺序:确保多个线程获取共享资源的锁的顺序一致,避免循环等待。
      • 超时机制:获取锁时设置超时时间,如果在规定时间内无法获取到锁,则放弃当前操作并释放已经获取的锁。
      • 资源分配策略:合理地对资源进行分配和释放,避免过多的资源占用。

死锁是一个复杂的问题,需要仔细分析和设计,采用合适的方法来预防和解决。在实际开发中,通常通过合理的资源使用和锁设计,以及合理的多线程调度策略来减少死锁的风险。

七、Java提供的高级并发工具

ReentrantLock和Condition

ReentrantLock和Condition是Java多线程编程中的重要组件,用于实现锁、同步和条件等机制。

  1. ReentrantLock(可重入锁): ReentrantLock是一个可重入的互斥锁,它提供了比内置锁更多的功能和灵活性。与synchronized关键字相比,ReentrantLock具有显式锁定和解锁的能力,并支持公平性选择。以下是ReentrantLock的特点:

    • 重入性:允许线程多次获取同一把锁。
    • 公平性选择:可以选择公平锁或非公平锁,默认为非公平锁。公平锁会按照线程的请求顺序来获取锁,而非公平锁则允许插队。
    • 可中断:支持在获取锁时被其他线程打断。
    • 条件变量:支持使用Condition对象实现条件等待和通知机制。
  2. Condition(条件): Condition是与锁相关联的条件对象,它提供了await()、signal()和signalAll()等方法来实现线程之间的协作。Condition可以理解为一种高级的等待/通知机制,用于解决线程等待特定条件满足时才能继续执行的场景。以下是Condition的主要方法:

    • await():当前线程进入等待状态,直到被其他线程调用signal()或signalAll()方法唤醒。
    • signal():唤醒一个在条件上等待的线程,并使其重新竞争锁。
    • signalAll():唤醒所有在条件上等待的线程。

使用ReentrantLock和Condition的一般步骤如下:

  1. 创建ReentrantLock对象:
 
 

java

复制代码

ReentrantLock lock = new ReentrantLock();

  1. 获取锁:
 
 

java

复制代码

lock.lock(); try { // 执行操作,可能涉及到等待条件满足的情况 } finally { lock.unlock(); // 解锁,必须在finally块中释放锁 }

  1. 创建与锁关联的Condition对象:
 
 

java

复制代码

Condition condition = lock.newCondition();

  1. 在特定条件下等待:
 
 

java

复制代码

lock.lock(); try { while (!condition满足) { condition.await(); // 当前线程进入等待状态,释放锁 } // 条件满足后执行操作 } finally { lock.unlock(); }

  1. 通知其他线程:
 
 

java

复制代码

lock.lock(); try { condition.signal(); // 唤醒单个等待线程 // 或 condition.signalAll(); // 唤醒所有等待线程 } finally { lock.unlock(); }

CountDownLatch

CountDownLatch(倒计时门闩)是Java多线程编程中的一种同步辅助工具,用于控制线程的执行顺序和等待。

  1. 概念: CountDownLatch是一种计数器,它可以让一个或多个线程等待其他线程完成一组操作后再继续执行。它的原理是通过一个计数器来实现,计数器的初始值可以设定,当计数器的值变为0时,等待的线程将被唤醒。

  2. 主要方法:

    • 构造方法:CountDownLatch(int count):创建一个指定初始计数的CountDownLatch对象。
    • await()方法:使当前线程等待,直到计数器变为0。
    • countDown()方法:将计数器减1。
  3. 使用步骤: a. 创建CountDownLatch对象,并指定计数初始值。 b. 由等待的线程调用await()方法,进入等待状态,直至计数器归零。 c. 其他执行线程完成一组操作后,调用countDown()方法,将计数器减1。 d. 当计数器归零时,等待的线程被唤醒,继续执行。

  4. 示例代码:

 
 

java

复制代码

// 创建一个CountDownLatch对象,计数器初始值为3 CountDownLatch latch = new CountDownLatch(3); // 等待的线程调用await()方法 new Thread(() -> { try { latch.await(); // 当计数器归零前,当前线程一直等待 System.out.println("等待的线程被唤醒,继续执行"); } catch (InterruptedException e) { e.printStackTrace(); } }).start(); // 其他执行线程完成一组操作后,调用countDown()方法 for (int i = 0; i < 3; i++) { final int index = i; new Thread(() -> { try { Thread.sleep(1000); // 模拟执行操作耗时 System.out.println("线程 " + index + " 完成操作"); } catch (InterruptedException e) { e.printStackTrace(); } finally { latch.countDown(); // 将计数器减1 } }).start(); }

在上述示例中,当计数器初始值为3时,等待的线程将等待其他3个线程完成操作后才会继续执行。每个执行线程完成操作后,都会调用countDown()方法将计数器减1。当计数器归零时,等待的线程被唤醒,输出"等待的线程被唤醒,继续执行"。

CountDownLatch常用于一些场景,如主线程等待所有子线程完成、并发任务的起始点控制等。通过CountDownLatch可以实现灵活的线程协作和同步。需要注意合理设置计数器初始值,并确保countDown()方法在合适的时机被调用,以避免线程永久等待。

CyclicBarrier

CyclicBarrier(循环栅栏)是Java多线程编程中的一种同步辅助工具,用于控制多个线程在某个点上相互等待,直到所有线程都到达该点后才能继续执行。

  1. 概念: CyclicBarrier是一种同步工具,它可以让一组线程在某个屏障点上相互等待,直到所有线程都到达该点后才能继续执行。与CountDownLatch不同的是,CyclicBarrier可以重复使用,因此称为循环栅栏。

  2. 主要方法:

    • 构造方法:CyclicBarrier(int parties):创建一个指定参与方数目的CyclicBarrier对象。
    • await()方法:使当前线程等待,直到所有参与方都到达栅栏。
    • await(long timeout, TimeUnit unit)方法:使当前线程等待,最多等待指定的时间。
  3. 使用步骤: a. 创建CyclicBarrier对象,并指定参与方数目。 b. 参与方线程调用await()方法,在栅栏处等待。 c. 当所有参与方都到达栅栏处时,栅栏被打破,所有线程继续执行。 d. 重复使用:通过CyclicBarrier的reset()方法可以重置栅栏,使得多个线程组可以重复使用CyclicBarrier。

  4. 示例代码:

 
 

java

复制代码

// 创建一个CyclicBarrier对象,参与方数目为3 CyclicBarrier barrier = new CyclicBarrier(3, () -> { System.out.println("所有参与方都到达栅栏,栅栏被打破,继续执行"); }); // 参与方线程调用await()方法,在栅栏处等待 for (int i = 0; i < 3; i++) { final int index = i; new Thread(() -> { try { Thread.sleep(1000); // 模拟执行操作耗时 System.out.println("线程 " + index + " 到达栅栏"); barrier.await(); // 等待其他参与方到达栅栏 System.out.println("线程 " + index + " 继续执行"); } catch (InterruptedException | BrokenBarrierException e) { e.printStackTrace(); } }).start(); }

在上述示例中,当参与方数目为3时,当所有3个线程都调用了await()方法后,它们将在栅栏处等待。当所有参与方都到达栅栏处时,栅栏被打破,CyclicBarrier的回调函数(通过构造方法中的Runnable参数实现)会被执行,并输出"所有参与方都到达栅栏,栅栏被打破,继续执行"。然后,所有线程继续执行。

CyclicBarrier常用于一些场景,如多线程任务的等待点控制和分阶段计算等。通过CyclicBarrier可以实现线程的同步和协作,将多个线程的执行步骤划分为多个阶段,并确保在每个阶段的所有线程都完成后再继续下一个阶段的执行。需要注意合理设置参与方数目,并确保await()方法在合适的时机被调用,以避免线程永久等待。

Semaphore

Semaphore(信号量)是一种并发控制工具,用于限制对资源的访问数量。它可以控制同时访问某个资源的线程数量,并提供了获取和释放许可的方法。

Semaphore 的主要概念和用法如下:

  1. 概念: Semaphore 是一个计数器,用来管理同时访问某个资源的线程数量。它维护一个许可证(permit)的计数,该计数表示还剩余多少个可用的许可证。线程在访问资源之前必须先获取许可证,如果许可证数量为 0,则线程将被阻塞,直到有其他线程释放许可证。

  2. 主要方法:

    • 构造方法:Semaphore(int permits):创建一个具有指定许可证数量的 Semaphore 对象。
    • acquire() 方法:获取一个许可证,如果没有许可证可用,则线程将被阻塞。
    • release() 方法:释放一个许可证,增加许可证的数量,并通知被阻塞的线程。
  3. 使用步骤: a. 创建 Semaphore 对象,并指定初始许可证数量。 b. 线程需要访问受 Semaphore 控制的资源前,调用 acquire() 方法获取许可证。 c. 如果许可证数量为 0,则线程将被阻塞,直到有其他线程释放许可证。 d. 访问资源完成后,线程调用 release() 方法释放许可证。

  4. 示例代码:

 
 

java

复制代码

// 创建一个初始许可证数量为3的 Semaphore 对象 Semaphore semaphore = new Semaphore(3); // 线程需要访问受 Semaphore 控制的资源前,调用 acquire() 方法获取许可证 try { semaphore.acquire(); // 访问资源 System.out.println("线程访问资源"); } catch (InterruptedException e) { e.printStackTrace(); } finally { // 访问资源完成后,调用 release() 方法释放许可证 semaphore.release(); }

在上述示例中,Semaphore 的许可证数量为 3,当一个线程需要访问资源时,它会调用 acquire() 方法获取许可证。如果此时已经没有许可证可用(许可证数量为 0),那么线程将被阻塞,直到有其他线程释放许可证(通过调用 release() 方法)。线程完成对资源的访问后,会调用 release() 方法释放许可证,增加许可证数量,并通知被阻塞的线程继续执行。

Semaphore 可以应用于各种并发场景,例如数据库连接池、线程池、有限资源的并发访问控制等。通过合理设置初始许可证数量,可以限制对资源的同时访问数量,从而避免资源过度竞争和线程过度阻塞的问题。

八、多线程的应用场景和实际案例

并行计算

在 Java 中,多线程可以用于实现并行计算,将一个计算任务分解成多个子任务,并通过多个线程同时执行这些子任务,从而加速计算过程。下面提供一个示例代码,演示了如何使用多线程进行并行计算:

 
 

java

复制代码

import java.util.concurrent.*; public class ParallelCalculation { public static void main(String[] args) { int numOfThreads = 4; // 线程数 // 创建线程池 ExecutorService executor = Executors.newFixedThreadPool(numOfThreads); // 定义任务 Callable<Long> task = () -> { long result = 0; for (long i = 1; i <= 100000; i++) { result += i; } return result; }; // 创建 Future 对象列表 Future<Long>[] futures = new Future[numOfThreads]; // 提交任务 for (int i = 0; i < numOfThreads; i++) { futures[i] = executor.submit(task); } // 计算总结果 long totalResult = 0; for (int i = 0; i < numOfThreads; i++) { try { totalResult += futures[i].get(); } catch (InterruptedException | ExecutionException e) { e.printStackTrace(); } } // 输出总结果 System.out.println("总结果:" + totalResult); // 关闭线程池 executor.shutdown(); } }

上述示例中,我们假设有一个计算任务,需要将1到100,000之间的所有数字相加。首先,我们创建了一个固定线程数的线程池(这里使用了 Executors.newFixedThreadPool 方法,创建一个具有固定线程数的线程池)。然后,定义了一个 Callable 对象作为任务,该任务会将1到100,000之间的数字相加,并返回结果。接下来,我们创建了一个 Future 对象数组,用于保存每个子任务的执行结果。然后,通过循环提交任务到线程池,并将返回的 Future 对象存储在数组中。最后,我们遍历 Future 对象数组,使用 get() 方法获取每个子任务的结果,并将其累加到总结果中。最终,输出总结果。

在并行计算中,需要注意以下几点:

  1. 合理选择线程数:线程数应根据计算任务的复杂度和可用的计算资源进行调整,过多或过少的线程数都可能导致性能下降。
  2. 任务拆分和合并:将大任务划分成多个小任务,并通过多个线程同时执行这些小任务,最后将结果合并,这可以提高计算效率。
  3. 线程安全:在并行计算中,多个线程可能同时访问共享的数据结构,因此需要考虑线程安全问题,并采用适当的同步机制。

请注意,示例代码仅为演示多线程并行计算的基本原理,实际应用中可能需要根据具体情况进行调整和扩展。

异步任务处理

在 Java 中,多线程可以应用于处理异步任务。异步任务是指不需要立即等待结果并阻塞主线程的任务,而是将其交给其他线程处理,主线程可以继续执行其他操作。Java 提供了多种机制来实现异步任务的处理,包括使用 Thread、Executor 框架和 CompletableFuture 等。下面提供一个示例代码,演示了如何使用 CompletableFuture 实现异步任务的处理:

 
 

java

复制代码

import java.util.concurrent.CompletableFuture; public class AsyncTask { public static void main(String[] args) { // 异步任务1:模拟耗时操作,返回结果为字符串 CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> { try { Thread.sleep(2000); // 模拟耗时操作 } catch (InterruptedException e) { e.printStackTrace(); } return "Hello"; }); // 异步任务2:模拟耗时操作,返回结果为字符串 CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> { try { Thread.sleep(3000); // 模拟耗时操作 } catch (InterruptedException e) { e.printStackTrace(); } return "World"; }); // 合并异步任务的结果 CompletableFuture<String> combinedFuture = future1.thenCombine(future2, (result1, result2) -> result1 + " " + result2); // 处理结果 combinedFuture.thenAccept(result -> System.out.println("合并结果:" + result)); // 主线程继续执行其他操作 System.out.println("主线程继续执行"); // 等待异步任务完成 try { combinedFuture.get(); // 阻塞主线程等待结果 } catch (Exception e) { e.printStackTrace(); } } }

上述示例中,我们使用 CompletableFuture 的 supplyAsync() 方法创建了两个异步任务(future1future2),这些任务模拟了耗时操作并返回字符串结果。然后,我们使用 thenCombine() 方法将两个异步任务的结果进行合并,并定义一个合并结果的处理函数。接下来,我们输出一条消息表示主线程继续执行其他操作。最后,我们使用 get() 方法阻塞主线程,直到合并异步任务的结果可用,并打印合并结果。

在异步任务处理中,需要注意以下几点:

  1. 使用适当的线程池:在提交异步任务时,可以通过传入自定义的 Executor 来指定线程池。这样可以控制线程池的大小、线程的属性以及任务队列等,从而更好地管理异步任务的执行。
  2. 合理处理异常:异步任务可能会抛出异常,因此需要合理处理异常情况,并确保不会影响其他任务和主线程的执行。
  3. 及时释放资源:在异步任务执行完成后,需要手动关闭或释放相关资源,以避免资源泄漏。

GUI程序多线程刷新

在 Java GUI 程序中,多线程刷新是为了保持界面的流畅性和响应性。由于 Java 的图形用户界面(GUI)库(如 Swing 和 JavaFX)都是单线程的,即事件派发线程(Event Dispatch Thread),所以在进行一些复杂或耗时的操作时,如果直接在事件派发线程中执行会导致界面卡顿或无响应。因此,我们可以使用多线程来实现耗时操作的并发执行,然后通过合适的方式将结果刷新到界面上。

下面是一个示例代码,演示了在 Java Swing 中如何使用多线程进行界面刷新:

 
 

java

复制代码

import javax.swing.*; import java.awt.*; import java.util.concurrent.TimeUnit; public class MultiThreadedGUI extends JFrame { private JLabel label; private JButton startButton; public MultiThreadedGUI() { setTitle("多线程刷新示例"); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setSize(300, 200); initGUI(); } private void initGUI() { setLayout(new FlowLayout()); label = new JLabel("等待开始..."); add(label); startButton = new JButton("开始"); startButton.addActionListener(e -> startTask()); add(startButton); } private void startTask() { startButton.setEnabled(false); // 禁用开始按钮 // 创建新线程执行耗时操作 Thread taskThread = new Thread(() -> { // 模拟一个耗时操作 try { TimeUnit.SECONDS.sleep(5); } catch (InterruptedException e) { e.printStackTrace(); } // 耗时操作完成后更新界面 SwingUtilities.invokeLater(() -> { label.setText("任务已完成"); startButton.setEnabled(true); // 启用开始按钮 }); }); taskThread.start(); // 启动新线程 } public static void main(String[] args) { SwingUtilities.invokeLater(() -> { MultiThreadedGUI gui = new MultiThreadedGUI(); gui.setVisible(true); }); } }

上述示例中,我们创建了一个简单的 Swing 窗口应用程序。窗口中有一个标签和一个按钮,点击按钮会启动一个耗时操作。在 startTask() 方法中,我们首先禁用开始按钮,然后创建一个新线程来执行耗时操作。在耗时操作完成后,通过调用 SwingUtilities.invokeLater() 方法将界面刷新的代码加入到事件派发线程中执行,从而更新界面的标签文本,并启用开始按钮。

这样做的好处是,耗时操作可以在单独的线程中执行,不会阻塞事件派发线程,使得界面保持流畅和响应。同时,通过使用 SwingUtilities.invokeLater() 方法,确保界面刷新的代码在事件派发线程中被执行,从而避免了多线程并发访问 GUI 元素可能导致的线程安全问题。

总结起来,Java GUI 程序中使用多线程刷新界面的一般步骤如下:

  1. 创建一个新线程,用于执行耗时操作。
  2. 在耗时操作完成后,通过 SwingUtilities.invokeLater() 方法将界面刷新的代码加入到事件派发线程中执行。
  3. 在界面刷新的代码中,更新 GUI 元素的状态或显示相应的内容。

需要注意的是,在进行界面刷新时,要确保对 GUI 元素进行访问的线程安全性,可以使用 Swing 提供的线程安全的组件或使用关键字 synchronized 来避免多线程并发访问导致的问题。

通过在 Java GUI 程序中使用多线程刷新,可以提高程序的响应性和用户体验。

猜你喜欢

转载自blog.csdn.net/BASK2312/article/details/131574656