并发

Yunge HuJava

1. 并发

多任务是操作系统的一种能力,看起来可以在同一时刻运行多个程序。并发执行的进程数不限于CPU数或CPU核心数,操作系统会为每个进程分配CPU时间片,给人并行处理的感觉。

多线程程序扩展了多任务的概念,单个程序看起来在同时完成多个任务。每个任务在一个线程执行,线程是控制线程的简称。

多进程和多线程本质的区别在于,每个进程都拥有自己的一套变量,线程之间则共享数据。尽管共享数据可能会导致安全性问题,但是共享数据使得线程之间的通信更高效和更容易。此外,线程更轻量级,资源消耗比进程小。

1.1. 什么是线程

创建线程并运行的方法如下:

  1. 将执行某个任务的代码放在某个实现了 Runnable 接口的类的 run 方法中。 Runnable 接口只有一个方法:

    public interface Runnable {
        public void run();
    }
    

    由于 Runnable 接口是一个函数式接口,所以也可以使用 lambda 表达式创建一个实例:

    Runnable r = () -> {...};
    
  2. 从这个 Runnable 构造一个 Thread 对象:

    var t = new Thread(r);
    
  3. 启动线程:

    t.start();
    

注意:不要调用 Thread 类或者 Runnable 对象的 run 方法,直接调用 run 方法只会在同一线程执行这个任务,而没有启动新的线程。只有调用 Thread.start 方法才会创建新的线程执行 run 方法。

1.2. 线程状态

线程可以有六种状态:

  • New
  • Runnable
  • Blocked
  • Waiting
  • Timed waiting
  • Terminated

要确定一个线程的状态,可以调用 getState 方法。

1.2.1. 新建线程

new 操作符创建一个线程时,这个线程还没有运行,这时它的状态就是新建,即 new

1.2.2. 可运行线程

一旦调用 start 方法,线程就处于可运行状态,即 runnable 状态。

一个可运行的线程可能正在运行,也可能没有在运行,操作系统会为线程提供具体的运行时间。

抢占式调度(Preemptive Scheduling)和协作式调度(Cooperative Scheduling)是操作系统中用于管理多个任务或线程的两种不同调度策略,它们决定了何时和如何分配处理器时间给不同的任务。

1. 抢占式调度(Preemptive Scheduling):

抢占式调度是一种操作系统调度策略,其中操作系统具有高度的控制权,可以在任务执行期间强制将处理器资源从一个任务切换到另一个任务。这种切换通常发生在任务达到其时间片(一段固定时间)的末尾或在高优先级任务准备执行时。

优点:

  • 允许操作系统对任务的优先级进行管理,以确保关键任务能够及时执行。
  • 避免了某个任务长时间占用处理器资源,导致其他任务无法响应。

缺点:

  • 需要额外的调度开销,因为需要频繁地切换任务。
  • 可能会引入竞争条件和死锁等问题,需要仔细管理。

2. 协作式调度(Cooperative Scheduling):

协作式调度是一种调度策略,其中任务或线程自愿地释放处理器资源。在这种策略下,任务通常在执行期间主动让出处理器,以便其他任务能够运行。协作式调度依赖于任务的合作,因此如果一个任务不合作或发生无限循环等情况,可能会导致系统停滞。

优点:

  • 相对简单,没有抢占式调度的开销。
  • 适用于某些特定情况下,如实时系统或嵌入式系统。

缺点:

  • 不适用于所有情况,因为它依赖于任务的自愿合作,可能会导致不公平分配和响应时间延迟。
  • 如果有一个任务无限期地占用处理器,可能会导致整个系统停滞。

总结:

抢占式调度和协作式调度各有其用途。抢占式调度适用于多任务环境中,其中任务需要按照其优先级获得处理器时间,并且需要及时响应外部事件。协作式调度通常在某些特定环境中使用,例如嵌入式系统或某些应用程序,其中任务的运行行为可以更精确地控制。选择哪种调度策略取决于系统需求和设计目标。

java.util.Thread 类中有一个 static void yield() 方法,它会使当前正在执行的线程向另一个线程交出运行权。

1.2.3. 阻塞和等待线程

当线程处于等待或阻塞状态时,它暂时是不活动的,它不执行任何代码并且消耗最少的资源,需要线程调度器重新激活这个线程。具体细节取决于它是怎样到达非活动状态的。

  • 阻塞:

    当一个线程试图获取一个内部的对象锁,而这个锁目前被其他线程占有时,该线程就会被阻塞。当所有其他线程都释放了这个锁,并且线程调度器允许该线程持有这个锁时,它将会变成非阻塞状态。

  • 等待

    当线程等待另一个线程通知调度器出现某个条件时,这个线程会进入等待状态。

  • 超时等待

    有几个方法有超时参数,调用这些方法会让线程进入计时等待状态,这一状态将一直保持到超时期满或者接收到适当的通知。

当一个线程阻塞或等待时,可以调度另一个线程运行。当一个线程被重新激活时,调度器会检查它是否具有比当前运行线程更高的优先级,如果是这样,调度器会剥夺当前某个正在运行的线程的运行权,选择运行一个新线程。

1.2.4. 终止线程

线程会由于以下两个原因之一终止:

  1. 由于 run 方法正常退出,线程自然终止;
  2. 由于一个没有捕获的异常终止了 run 方法,现成意外终止。

不要调用已经被废弃的 stop 方法终止线程。

1.3 线程属性

1.3.1 中断线程

除了已经废弃的 stop 方法,没有办法强制一个线程终止。不过可以调用 interrupt 方法请求终止一个线程。

当一个线程调用 interrupt 方法时,就会设置线程的中断状态,这是每个线程都有的 boolean 标志,每个线程都应该不时检查这个标志,以判断线程是否被中断。

要确定是否设置了中断状态,首先调用静态方法 Thread.currentThread 获得当前线程,然后调用 isInterrupted 方法:

while (!Thread.currentThread().isInterrupted() && ...) {
    ...;
}

但是如果线程被阻塞,就无法检查中断状态。在一个被 sleepwait 调用阻塞或等待的线程上调用 interrupt 方法时,那个阻塞或等待调用将被 InterruptedException 异常中断。

有一些阻塞 I/O 调用不能被中断,对此应考虑选择可以中断的调用。

被中断的线程可以自行选择如何响应中断,某些线程非常重要,应该处理这个异常并继续执行;或者线程只希望将中断解释为终止请求,这种线程的 run 方法有如下形式:

Runnable r = () -> {
    try {
        ...;
        while (!Thread.currentThread().isInterrupted() && ...) {
            ...;
        }
        // exit normally
    }
    catch (InterruptedException e) {
        // thread was interrupted during sleep or wait
        // exit unexpectedly
    }
    finally {
        // cleanup, if required
    }
    // exiting the run method terminates the thread
}

如果在每次工作迭代之后都调用 sleep 方法或其他可中断方法,那么 isInterrupted 检查就没有意义了。如果设置了中断状态并调用 sleep 方法,它不会休眠,而是会清除中断状态并抛出 InterruptedException 异常。在这种情况下,应该如下组织代码:

Runnable r = () -> {
    try {
        ...;
        // !!!!!!!!!!!!!!!!!
        // !!! look here !!!
        // !!!!!!!!!!!!!!!!!
        while (...) {
            ...;
            Thread.sleep(delay);
        }
        // exit normally
    }
    catch (InterruptedException e) {
        // thread was interrupted during sleep or wait
        // exit unexpectedly
    }
    finally {
        // cleanup, if required
    }
    // exiting the run method terminates the thread
}

有两个类似的方法,分别是 interruptedisInterrupted 方法,前者是静态方法,它检查当前线程是否中断,并且会清除该线程的中断状态。而后者是一个实例方法,可以用来检查是否有线程被中断,它不会改变中断状态。

有些代码在底层抑制了 InterruptedException 异常,这样做是不合适的。如果实在想不到在 catch 子句中做什么有意义的工作,可以有两个选择:

  1. catch 子句中调用 Thread.currentThread().interrupt() 设置中断状态,以便调用者检测。
  2. 或者更好的选择是抛出这个异常,供调用者捕获这个异常并处理。

1.3.2 守护线程

可以通过调用

t.setDaemon(true);

将一个线程转换为守护线程,它的用途是为其他线程提供服务,例如计时器线程和清空过时缓存项的线程。只剩下守护线程时,虚拟机就会退出。

这个方法必须在线程启动之前调用。

1.3.3 线程名

可以使用 setName 方法为线程设置任何名字,不过即使不设置,线程也会有默认的名字。

这在线程转储时可能很有用。

1.3.4 未捕获异常的处理器

线程的 run 方法不能抛出任何检查型异常,非检查型异常可能会导致线程终止。这种情况下,线程会死亡。

1.3.5 线程优先级

线程具有优先级,可以使用 setPriority 方法设置线程优先级。

默认会继承创造它的线程的优先级。

线程优先级最低为 MIN_PRIORITY ,在 Thread 类中定义为 1 ;最高为 MAX_PRIORITY ,定义为 10

同时 NORM_PRIORITY 定义为 5

线程调度器选择新线程时,优先选择优先级高的线程。

但是线程优先级高度依赖于系统。现在不建议使用线程优先级了。

1.4 同步

1.4.1 锁对象

Java 提供了 ReentrantLock 类用于实现锁,该类保护代码块的基本结构如下:

myLock.lock();
try {
    // critical section
} finally {
    mylock.unlock();
}

这个结构确保任何时刻只有一个线程进入临界区。

必须把解锁操作放在 finally 子句中,否则一旦锁没有释放,其他线程将永远阻塞。

使用锁不能使用 try-with-resources 语句。

每个对象都有自己的锁对象,锁对象可以保证串行化访问。

这个锁是可重入锁,线程可以反复获得已经拥有的锁。锁有一个持有计数,用于跟踪对 lock 方法的嵌套调用。

注意:要确保不能由于抛出异常语句而绕过临界区中的代码。如果在临界区代码结束之前中抛出异常,锁确实被释放,但是对象可能处于被破坏的状态。需要保证当前操作执行完毕后,其他线程能够正常访问这个对象。

关于这个锁对象还有一些特性,下面是来自 ChatGPT 的回答:

ReentrantLock(可重入锁)是 Java 中用于实现锁的一个类,它提供了与 synchronized 关键字类似的功能,但更加灵活。ReentrantLock 是在 Java 5 中引入的,相比于 synchronized,它提供了更多的功能和更灵活的控制,例如可中断的锁、公平性选择等。

下面是 ReentrantLock 类的一些重要特性和用法:

  1. 可重入性:与 synchronized 关键字类似,ReentrantLock 支持线程对同一个锁的重入,即同一个线程可以多次获取同一把锁而不会造成死锁。这种机制对于递归函数或者一个方法内部调用其他加锁方法时非常有用。
  2. 公平性选择:ReentrantLock 提供了公平锁和非公平锁两种模式。公平锁会按照请求的顺序来获取锁,而非公平锁则不保证获取锁的顺序。通过构造函数可以选择使用哪种模式,默认情况下是非公平锁。
  3. 可中断性:ReentrantLock 提供了可中断的锁获取操作,即可以在等待锁的过程中中断线程。通过 lockInterruptibly() 方法可以实现可中断的锁获取。
  4. 条件变量:ReentrantLock 提供了 Condition 接口及其实现类,用于在等待某个特定条件的线程之间进行通信。通过调用 ReentrantLock 的 newCondition() 方法可以获取一个与该锁相关联的 Condition 实例,然后可以使用 await()、signal()、signalAll() 方法来实现线程的等待和唤醒。
  5. tryLock() 方法:ReentrantLock 提供了 tryLock() 方法,用于尝试获取锁而不会一直等待。该方法会立即返回一个 boolean 值,表示是否获取到了锁。
  6. 锁释放:ReentrantLock 与 synchronized 不同,它需要显式地释放锁。通常使用 try-finally 块来确保锁的释放,以避免因异常导致锁无法释放而造成死锁。

总的来说,ReentrantLock 类提供了一种灵活、可重入、可中断、可选择公平性的锁机制,适用于对锁粒度、锁操作进行更精细控制的情况。

1.4.2 条件对象

条件对象又被称为条件变量。

一个锁对象可以有一个或多个关联的条件对象。可以使用 newCondition 方法获得一个条件对象。

调用这个条件对象的 await 方法会让当前线程暂停并放弃锁,从而允许另一个线程执行。

调用这个方法的线程会进入这个条件的等待集,它会等到另一个线程在同一条件上调用 signalAll 方法。

这个调用会激活等待这个条件的所有线程,一旦锁可用,线程就会从 await 调用处返回并继续执行。

由于此时并不保证条件可用,所以还需要再次测试条件,所以 await 调用一般位于循环中:

while (!(OK)) {
    condition.await();
}

最终必须有线程激活其他线程,否则有可能所有线程都被阻塞,程序永远挂起,造成死锁现象。

从经验上讲,只要一个对象的状态有变化,并且可能有利于正在等待的线程,就可以调用 signalAll 方法。

另一个方法 signal 会随机选择一个线程并激活,这个方法更高效但是存在一定危险,不能保证激活的线程可以正常运行。

实现同步机制会有效率损失。

1.4.3 示例代码

下面是使用锁对象和条件对象实现并发的示例代码:

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

class SharedResource {
    private int count = 0;
    private final Lock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();

    public void increment() throws InterruptedException {
        lock.lock();
        try {
            // 等待 count 变为 0
            while (count != 0) {
                condition.await(); // 等待条件满足
            }
            // 执行增加操作
            count++;
            System.out.println("Incremented: " + count);
            // 唤醒其他线程
            condition.signal();
        } finally {
            lock.unlock();
        }
    }

    public void decrement() throws InterruptedException {
        lock.lock();
        try {
            // 等待 count 变为 1
            while (count == 0) {
                condition.await(); // 等待条件满足
            }
            // 执行减少操作
            count--;
            System.out.println("Decremented: " + count);
            // 唤醒其他线程
            condition.signal();
        } finally {
            lock.unlock();
        }
    }
}

public class Main {
    public static void main(String[] args) {
        SharedResource sharedResource = new SharedResource();

        Thread thread1 = new Thread(() -> {
            try {
                sharedResource.increment();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        Thread thread2 = new Thread(() -> {
            try {
                sharedResource.decrement();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        thread1.start();
        thread2.start();
    }
}

1.4.4 synchronized 关键字

每个对象都有一个内部锁,如果一个方法声明时有 synchronized 关键字,那么对象的锁将完整保护整个方法。

或者可以使用这个关键字保护某一段代码块。

两种结构如下:

synchronized (obj) {
    // 同步代码块
}
public synchronized void method() {
    // 同步方法
}

内部对象锁只有一个关联条件,条件对象的 await & signal & signalAll 方法分别对应于 wait & notify & notifyAll 方法。

内部锁和条件存在使用限制:

  1. 不能中断一个正在尝试获取锁的线程。
  2. 不能指定尝试获取锁的超时时间。
  3. 每个锁只有一个条件,这很低效。

内部锁和条件灵活性不如前面的锁对象和条件对象,但是代码较为简洁,需要根据场景选择合适的方法。

下面是对同一场景的两种实现。

使用锁对象:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

class CounterWithLock {
    private int count = 0;
    private final Lock lock = new ReentrantLock();

    public void increment() {
        lock.lock(); // 获取锁
        try {
            count++; // 执行计数器加一操作
        } finally {
            lock.unlock(); // 释放锁
        }
    }

    public int getCount() {
        return count;
    }
}

public class MainWithLock {
    public static void main(String[] args) throws InterruptedException {
        CounterWithLock counter = new CounterWithLock();

        // 创建多个线程并启动
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    counter.increment(); // 调用计数器的增加方法
                }
            }).start();
        }

        // 等待所有线程执行完毕
        Thread.sleep(2000);

        // 输出最终的计数值
        System.out.println("Count with Lock: " + counter.getCount());
    }
}

使用 synchronized 关键字:

class CounterWithSynchronized {
    private int count = 0;

    public synchronized void increment() {
        count++; // 执行计数器加一操作
    }

    public synchronized int getCount() {
        return count;
    }
}

public class MainWithSynchronized {
    public static void main(String[] args) throws InterruptedException {
        CounterWithSynchronized counter = new CounterWithSynchronized();

        // 创建多个线程并启动
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    counter.increment(); // 调用计数器的增加方法
                }
            }).start();
        }

        // 等待所有线程执行完毕
        Thread.sleep(2000);

        // 输出最终的计数值
        System.out.println("Count with Synchronized: " + counter.getCount());
    }
}

对于静态方法,会使用这个类的 class 对象的内部锁。

对于同步代码块,不要使用字符串字面量和基本类型包装器作为锁。

使用类的锁要从特定类上获得锁,而不是从 getClass() 方法获得锁。

有时会使用一个对象的锁来实现额外的原子操作,称为客户端锁定。不过客户端锁定是非常脆弱的,通常不建议使用。

1.4.5 监视器概念

在计算机科学中,监视器是一种并发编程的同步机制,用于确保多个线程能够安全地访问共享资源。监视器的概念最初由 C.A.R. Hoare 在他的经典论文中提出,后来被应用到了许多编程语言和操作系统中,其中包括 Java。

在 Java 中,每个对象都拥有一个内置的监视器(也称为锁),它用于实现对象的同步。这个内置的监视器可以通过 synchronized 关键字来进行访问和操作。当一个线程进入了一个使用 synchronized 关键字修饰的方法或代码块时,它就会获取该对象的监视器(锁),其他线程则会被阻塞,直到该线程执行完毕释放锁。

监视器的主要特点包括:

  1. 互斥性:一次只允许一个线程持有监视器(锁),其他线程必须等待锁释放后才能继续执行。
  2. 条件等待:监视器可以关联一个或多个条件,线程可以在条件不满足时通过 wait() 方法进入等待状态,并在条件满足时通过 notify()notifyAll() 方法唤醒等待的线程。
  3. 原子性:监视器可以确保一系列操作的原子性,即一个线程在执行完毕释放锁之前,其他线程无法看到中间状态。

监视器提供了一种简单而有效的同步机制,帮助开发者编写线程安全的代码。在 Java 中,监视器的内置实现使得并发编程变得更加容易,开发者可以通过 synchronized 关键字来实现对象级别的同步。

不过 Java 对象和监视器存在三点不同,削弱了线程安全性:

  1. 字段不要求私有。
  2. 方法不要求同步。
  3. 内部锁对客户可用。

1.4.6 volatile 字段

该关键字为实例字段的同步访问提供了一种免锁机制,如果将实例字段声明为 volatile 字段,那么编译器和虚拟机就会考虑到该字段可能并另一个线程并发更新。

但是 volatile 变量不能提供原子性。

下面是来自 ChatGPT 的介绍:

在 Java 中,volatile 是一个关键字,用来修饰变量。当一个变量被 volatile 关键字修饰时,意味着这个变量的值可能会被多个线程同时修改,因此在访问这个变量时,线程会直接从内存中读取该变量的值,而不是从线程的工作内存中读取。此外,对于 volatile 变量的写操作也会立即同步到内存中,而不是延迟到线程切换时才进行。

volatile 字段的主要特点包括:

  1. 可见性:当一个线程修改了 volatile 变量的值后,其他线程会立即看到最新的值,因为修改操作会直接同步到主内存中,其他线程读取时会直接从主内存中获取。
  2. 禁止指令重排序volatile 变量的写操作不仅保证了可见性,还会禁止指令重排序,即保证了 volatile 变量赋值操作的有序性。

然而,需要注意的是,volatile 并不保证原子性。即使是 volatile 变量,在多线程环境下也可能会出现线程安全问题。如果需要保证原子性操作,例如对一个变量的读取-修改-写入操作是一个完整的原子操作,那么需要使用更强大的原子操作类,比如 AtomicInteger

通常情况下,volatile 变量适用于以下场景:

  • 变量的写操作不依赖于变量的当前值,或者能够确保只有单个线程修改变量的值。
  • 对变量的读操作不依赖于变量的当前值,或者能够确保读操作在变量被修改之后执行。

总的来说,volatile 提供了一种简单而轻量级的线程间通信机制,用于确保变量的可见性和有序性,但在保证线程安全和原子性方面有限制。

1.4.7 final 变量

对于对象的创建,如果将字段声明为 final 变量,那么其他线程会在对象的构造器完成构造之后才能看到这个变量。

final var counts = new HashMap<String, Double>();

否则其他线程可能看到的是 null 而不是新构造的对象。

1.4.8 原子性

如果对共享变量除了赋值之外不做其他操作,那么可以如上文所说将变量声明为 volatile 变量。

如果需要做更加复杂的操作,那么可以考虑使用 java.util.concurrent.atomic 包的类。

这些类提供了高效的机器指令来保证这些操作的原子性。

1.4.9 死锁

死锁是指在多线程或多进程的环境中,两个或多个线程或进程因为互相持有对方所需的资源而相互等待,导致它们都无法继续向前执行的情况。

要发生死锁,通常需要满足以下四个条件,这四个条件被称为死锁的必要条件:

  1. 互斥条件(Mutual Exclusion):至少有一个资源是不能被共享的,只能由一个线程或进程独占。
  2. 请求与保持条件(Hold and Wait):一个线程或进程可以持有已经分配到的资源,并且可以请求新的资源。
  3. 不可抢占条件(No Preemption):系统中的资源只能由持有它的线程或进程释放,其他线程或进程无法强行抢占。
  4. 循环等待条件(Circular Wait):存在一个线程或进程的资源等待序列,使得每个线程或进程都在等待下一个线程或进程所持有的资源。

当这些条件同时满足时,就可能导致死锁的发生。一旦发生死锁,各线程或进程都将永远等待下去,除非外部的干预打破了死锁的环境。

在多线程编程中,死锁是一种常见的问题,因此开发人员需要谨慎设计和管理多线程间的资源竞争,以避免死锁的发生。常见的解决死锁的方法包括:避免循环等待、按顺序申请资源、引入超时机制、死锁检测与恢复等。

在 Java 标准库中曾经存在 stop 方法和 suspend 方法,以及用来恢复 suspend 方法的 resume 方法。现在这些方法已经被废除,因为它们会造成不安全或者造成死锁现象。

1.4.10 线程局部变量

共享变量可能会存在问题。

不过有时可以避免共享变量,使用 ThreadLocal 辅助类为各自线程提供各自的实例。

可以使用这个辅助类为各自线程提供一个单独的生成器。

线程局部变量有时用于向协作完成某个任务的所有方法提供对象,而不必在调用者之间传递这个对象。

下面是来自 ChatGPT 的介绍:

线程局部变量是一种在多线程环境下,每个线程都拥有自己独立的变量副本的机制。线程局部变量在 Java 中通过 ThreadLocal 辅助类来实现。

ThreadLocal 是一个泛型类,在使用时需要指定变量的类型。它提供了一种线程绑定机制,使得每个线程都可以拥有自己的变量副本,而不会受到其他线程的影响。

下面是 ThreadLocal 类的一些主要方法和用法:

  1. set(T value):设置当前线程的变量副本的值为指定的值。
  2. T get():获取当前线程的变量副本的值。
  3. remove():移除当前线程的变量副本。

线程局部变量的主要用途包括:

  • 避免线程安全问题:每个线程拥有自己的变量副本,不会受其他线程的影响,可以避免线程安全问题。
  • 线程上下文信息传递:可以在多线程环境中方便地传递线程相关的上下文信息,而不需要显式地传递参数。
  • 减少线程间共享资源的竞争:通过线程局部变量,可以减少线程之间对共享资源的竞争,提高程序性能。

总的来说,ThreadLocal 辅助类提供了一种简单而有效的方式来实现线程局部变量,用于解决多线程环境下的线程安全和上下文传递等问题。

1.5 线程安全的集合

1.5.1 阻塞队列

阻塞队列是一种线程安全的队列,它支持在队列为空或队列已满时进行阻塞等待的操作。阻塞队列常用于生产者-消费者模式中,可以有效地进行线程间的数据交换和协作。

Java 提供了 BlockingQueue 接口及其多个实现类,常见的包括:

  1. LinkedBlockingQueue:基于链表实现的阻塞队列,可以指定容量,如果队列已满或队列为空时,插入元素或者取出元素的线程会阻塞。
  2. ArrayBlockingQueue:基于数组实现的阻塞队列,必须指定容量,如果队列已满或队列为空时,插入元素或者取出元素的线程会阻塞。
  3. PriorityBlockingQueue:基于优先级堆实现的无界阻塞队列,插入元素时会根据元素的优先级进行排序,取出元素时会取出优先级最高的元素。
  4. SynchronousQueue:一种特殊的阻塞队列,其内部没有存储元素的容量,每个插入操作必须等待一个对应的删除操作,反之亦然。通常用于线程之间的直接传输数据。

阻塞队列的常用方法包括:

  • put(E e):向队列尾部插入元素,如果队列已满,则阻塞直到队列有空闲空间。
  • take():从队列头部取出元素,如果队列为空,则阻塞直到队列有元素可取。
  • offer(E e, long timeout, TimeUnit unit):向队列尾部插入元素,在指定的超时时间内等待队列有空闲空间。
  • poll(long timeout, TimeUnit unit):从队列头部取出元素,在指定的超时时间内等待队列有元素可取。

阻塞队列提供了一种方便且高效的方式来进行线程间的数据交换和协作,它可以避免手动实现线程间的等待/通知机制,从而简化了并发编程的复杂度。

1.5.2 映射&集&队列

  1. 映射(Map)
    • ConcurrentHashMap:基于分段锁实现的线程安全的哈希表,可以在并发访问时保证线程安全。
    • Hashtable:早期提供的线程安全的哈希表,使用 synchronized 来实现线程安全,但性能相对较差。
  2. 集合(Set/List)
    • CopyOnWriteArrayList:线程安全的 ArrayList,采用写时复制的方式实现,适合读多写少的场景。
    • CopyOnWriteArraySet:线程安全的 HashSet,基于 CopyOnWriteArrayList 实现。
    • ConcurrentSkipListSet:基于跳表实现的线程安全的有序集合,支持高并发访问。
    • ConcurrentSkipListMap:基于跳表实现的线程安全的有序映射。
  3. 队列(Queue)
    • LinkedBlockingQueue:基于链表实现的线程安全的阻塞队列,支持无界队列和有界队列。
    • ArrayBlockingQueue:基于数组实现的线程安全的阻塞队列,必须指定容量,支持有界队列。
    • PriorityBlockingQueue:线程安全的优先级队列,基于优先级堆实现,支持高并发访问。
    • ConcurrentLinkedQueue:基于链表实现的线程安全的无界队列,适用于高并发场景。

1.5.3 并行数组算法

Java 的 java.util.Arrays 类提供了一些用于操作数组的静态方法,其中包括一些并行化算法,这些算法利用了多线程来加速对数组的处理。主要的并行化算法包括 parallelSort() 方法。

  • parallelSort() 方法用于对数组进行并行排序。它会根据数组的类型和长度来选择合适的排序算法,并使用多线程来加速排序过程。

  • 这个方法适用于对基本数据类型数组和对象数组进行排序。对于基本数据类型数组,它使用双轴快速排序算法来进行排序;对于对象数组,则使用归并排序算法。

  • 使用并行化排序算法时,需要注意排序的数组规模和硬件环境,因为并行化排序算法可能会在小规模数组上带来额外的性能开销。

  • 例如:

    int[] arr = {5, 2, 7, 1, 8, 3};
    Arrays.parallelSort(arr); // 对数组进行并行排序
    

除了 parallelSort() 方法之外,java.util.Arrays 类还提供了其他一些用于数组操作的静态方法,如 parallelPrefix(), parallelSetAll() 等,它们也可以在一定程度上利用并行化来提高性能。这些方法在处理大规模数组时可能会更有效地利用多核处理器的性能,从而加速数组处理过程。

1.6 任务和线程池

线程池中包含很多准备运行的线程,为线程池提供一个 Runnable 对象,其中会有一个线程执行这个 run 方法。当 run 方法结束后,这个线程不会死亡,而是留在线程池中等待运行下一个任务。

1.6.1 Callable 和 Future

在 Java 中,CallableFuture 是用于支持异步编程的接口和类。

它们通常与线程池一起使用,用于执行异步任务并获取任务执行结果。

  1. Callable 接口

    public interface Callable<V> {
        V call() throws Exception;
    }
    

    该方法可以返回执行结果,并且允许抛出异常。

    该方法执行异步任务的主体逻辑,并返回一个泛型类型的结果。

  2. Future 接口

    public interface Future<V> {
        V get();
        V get(long timeout, TimeUnit unit);
        void cancel(boolean mayInterrupt);
        boolean isCancelled();
        boolean isDone();
    }
    

    该接口表示一个异步计算的结果,它提供了方法来检查任务是否已经完成、等待任务的完成、取消任务的执行,并获取任务的执行结果。

    对第一个 get 方法的调用会阻塞当前线程,直到计算结果完成。

    对第二个 get 方法的调用同样会阻塞当前线程,如果调用超时,会抛出 TimeoutException 异常。

    如果运行该计算的线程被中断,两个方法都会抛出 InterruptedException 异常。

1.6.2 执行器

Executors 类是 Java 并发包中用于创建和管理线程池的工厂类。它提供了一系列静态方法来创建不同类型的线程池,简化了线程池的创建和管理过程。

以下是 Executors 类的一些常用方法:

  1. newFixedThreadPool(int nThreads)

    创建一个固定大小的线程池,该线程池中包含固定数量的线程。当线程池中的所有线程都处于活动状态时,新的任务会在等待队列中排队。

  2. newCachedThreadPool()

    创建一个根据需要自动调整大小的线程池。该线程池会根据任务的数量动态调整线程池的大小,当有新任务到来时,如果线程池中有空闲线程,则会复用空闲线程,否则会创建新的线程。

  3. newSingleThreadExecutor()

    创建一个单线程的线程池,该线程池中只包含一个线程,用于顺序执行任务。

  4. newScheduledThreadPool(int corePoolSize)

    创建一个固定大小的线程池,用于执行定时任务和周期性任务。

  5. newSingleThreadScheduledExecutor()

    创建一个单线程的线程池,用于执行定时任务和周期性任务。

  6. newWorkStealingPool()

    创建一个工作窃取线程池,线程池的大小根据 CPU 核心数自动调整,每个线程都维护自己的任务队列,可以从其他线程的队列中窃取任务执行。

  7. newFixedThreadPool(int nThreads, ThreadFactory threadFactory)

    newCachedThreadPool(ThreadFactory threadFactory)

    newSingleThreadExecutor(ThreadFactory threadFactory)

    newScheduledThreadPool(int corePoolSize, ThreadFactory threadFactory)

    这些方法与上述方法类似,不同之处在于可以指定自定义的线程工厂来创建线程。

通过 Executors 类提供的这些静态方法,可以方便地创建不同类型的线程池,根据具体的需求来管理线程池,从而更有效地管理和执行多线程任务。

要关闭线程池,可以调用 ExecutorService 接口的 shutdown() 方法或 shutdownNow() 方法。这两种方法都用于停止接受新的任务,并尝试完成所有已经存在的任务。

  1. shutDown 方法
    • shutdown() 方法是一个温和的关闭方法,它会停止接受新的任务,并允许已经提交的任务(包括等待执行和正在执行的任务)继续执行直到完成。
    • 调用 shutdown() 方法后,线程池不会立即关闭,而是等待所有任务执行完毕,然后关闭线程池。在关闭过程中,可以通过 isTerminated() 方法判断线程池是否已经关闭,或通过 awaitTermination() 方法等待线程池完全关闭。
  2. shutdownNow() 方法:
    • shutdownNow() 方法是一个强制关闭方法,它会立即停止接受新的任务,并尝试终止正在执行的任务。
    • 调用 shutdownNow() 方法后,线程池会立即尝试停止所有正在执行的任务,并返回尚未开始执行的任务列表。
    • shutdown() 方法不同,shutdownNow() 方法不会等待已经提交的任务执行完毕,而是立即关闭线程池。

需要注意的是,调用 shutdown()shutdownNow() 方法后,线程池就不能再提交新的任务了。因此,在调用这些方法之后,应该确保已经提交的任务能够被正确执行完毕,否则可能会导致任务丢失或者资源泄漏的问题。

1.6.3 控制任务组

通过上面的线程池和执行器,能够控制整个任务组的工作。

主要使用 java.util.concurrent.ExecutorService 类和 java.util.concurrent.ExecutorCompletionService 类中提供的方法。

1.6.4 fork-join 框架

Fork-Join 框架是 Java 并发包中提供的一个用于并行计算的框架,主要用于解决分治算法中的任务并行化问题。它的设计目标是让开发者能够轻松地编写并行化的程序,利用多核处理器的性能优势来加速任务的执行。

Fork-Join 框架的核心概念包括以下几点:

  1. 分治思想:Fork-Join 框架基于分治算法,将大任务划分为多个小任务,然后并行地执行这些小任务,最后合并各个子任务的结果得到最终结果。这种分治思想非常适合于并行计算,能够充分利用多核处理器的性能。
  2. 工作窃取:Fork-Join 框架通过工作窃取(Work Stealing)算法来实现任务的调度和执行。每个线程都维护一个双端队列(Deque),当自己的队列为空时,会去其他线程的队列中窃取任务执行,从而保持线程的利用率。
  3. 任务分解:Fork-Join 框架中的任务可以通过递归的方式进行分解,每次分解任务时,会判断任务的规模是否足够小,如果足够小则直接执行,否则继续分解任务直到满足执行条件。
  4. 核心类:Fork-Join 框架的核心类是 ForkJoinPoolForkJoinTaskRecursiveTaskForkJoinPool 是线程池的实现类,用于管理线程的创建和销毁;ForkJoinTask 是任务的抽象类,定义了执行任务的方法;RecursiveTask 是递归任务的抽象类,继承自 ForkJoinTask,用于表示可以递归分解的任务。

Fork-Join 框架的使用步骤通常包括以下几点:

  1. 继承 RecursiveTask 类,实现自定义的递归任务。
  2. 在任务的 compute() 方法中,实现任务的逻辑,并根据任务的规模决定是否继续分解任务或直接执行任务。
  3. 创建 ForkJoinPool 对象,将任务提交到线程池中执行。
  4. 获取任务的执行结果,并进行合并操作得到最终结果。

Fork-Join 框架提供了一种简单而高效的方式来实现任务的并行化,特别适用于处理递归和分治算法的场景。它能够充分利用多核处理器的性能优势,加速任务的执行,从而提高程序的性能和响应性。

1.7 异步计算

异步计算主要使用 CompletableFuture 类。

需要注册一个回调函数,一旦结果可用,就会在某个线程中利用该结果调用这个回调。

这个类提供了一个机制,可以将多个异步任务组合成一个处理流水线。

1.8 进程

1.8.1 创建和启动进程

ProcessBuilder 类和 Process 类是 Java 中用于创建和管理外部进程的两个重要类。

ProcessBuilder

  • ProcessBuilder 类用于创建一个外部进程的构造器。它提供了一种更灵活的方式来创建和启动外部进程,可以设置进程的命令、参数、工作目录等信息。
  • 使用 ProcessBuilder 可以创建一个进程构造器,并通过 command() 方法设置进程的命令和参数,然后调用 start() 方法启动进程。

Process

  • Process 类表示一个正在运行的外部进程。通过 Process 对象,可以获取进程的输入输出流,等待进程执行完成,获取进程的返回值等信息。
  • 使用 Process 对象可以执行各种操作,如读取进程的标准输出、标准错误输出,发送输入给进程等。

主进程可以和这些进程通过管道通信。

1.8.2 进程句柄

在 Java 中,通过 Process 对象可以获取外部进程的句柄。进程句柄通常用于在操作系统级别标识和管理进程,但在 Java 中,Process 对象并不直接提供获取进程句柄的方法。不过,可以通过一些间接的方式来获取进程的句柄,具体取决于操作系统和平台的不同。

在 Linux 和 Unix-like 系统中,可以使用 /proc 文件系统来获取进程句柄。每个进程都有一个相应的目录,其中包含了该进程的各种信息,包括进程 ID、状态等。通过读取相应的目录,可以获取进程的句柄。

在 Windows 系统中,可以使用 ProcessHandle 类来获取进程句柄。ProcessHandle 类是 Java 9 中新增的一个类,用于表示和操作本地进程。

获取 ProcessHandle 有四种方法:

  1. 给定一个 Process 对象 p 可以通过 p.toHandle() 方法得到他的句柄。
  2. 给定进程 pid 可以通过 ProcessHandle.of(pid) 方法得到句柄。
  3. 通过 Process.current 方法得到运行这个 Java 虚拟机的进程的句柄。
  4. 通过 ProcessHandle.allProcesses() 可以得到所有对当前进程可见的操作系统进程的 Stream<ProcessHandle> 对象。

通过句柄可以得到进程的相关信息。

1.8.3 僵尸进程和孤儿进程

在使用 ProcessBuilder 类和 Process 类创建和管理外部进程时,通常不会出现僵尸进程和孤儿进程的情况,因为 Java 在这方面提供了良好的处理机制。

  1. 僵尸进程
    • 僵尸进程是指一个已经终止但是其父进程没有调用 wait()waitpid() 来获取其状态的子进程。
    • 在 Java 中,当一个 Process 对象创建的外部进程终止时,Process 对象会自动处理并清理相关资源,不会留下僵尸进程。
  2. 孤儿进程
    • 孤儿进程是指父进程退出了,但是子进程还在运行的情况。
    • 在 Java 中,如果创建的外部进程是由 Java 虚拟机启动的,那么这个外部进程的父进程就是 JVM 进程。当 JVM 进程退出时,它会自动终止所有由它启动的子进程,因此不会出现孤儿进程的情况。

总的来说,Java 提供了良好的进程管理机制,使用 ProcessBuilder 类和 Process 类创建和管理外部进程时,通常不会出现僵尸进程和孤儿进程的情况。

Last Updated 2024/9/22 16:35:40