1. 并发编程之进程、线程与基础

65 阅读21分钟

1. 进程与线程

进程

进程是操作系统中程序执行的实例,进程拥有自己的独立地址空间,这意味着不同进程之间的内存空间是隔离的,它们不能直接访问彼此的内存数据。每个进程都像是计算机上运行的一个独立的程序副本,拥有独立的生命周期和系统资源。

线程

线程是进程中执行运算的最小单位,是CPU调度和分配的基本单位,一个进程中可以包含一个或多个线程,这些线程共享相同的地址空间(包括代码段、数据段以及其他资源),它们可以直接访问进程内的所有变量和对象。每个线程都有自己的程序计数器、栈和一组寄存器,这样就可以同时执行多个任务,也就是所谓的“并发执行”。

2. Java线程

JVM自行启动的线程

Monitor Ctrl-Break //监控 Ctrl-Break 中断信号的
Att ach Listener //内存 dump,线程 dump,类信息统计, 获取系统属性等
Signal Dispatcher// 分发处理发送给 JVM 信号的线程
Finalizer // 调用对象 finalize 方法的线程
Reference Handler//清除 Reference 的线程
main //main 线程, 用户程序入口

线程启动与终止

启动

  1. 集成Thread类重写run()方法
  • 定义一个类,让它继承自 java.lang.Thread 类。
  • 在子类中重写 run() 方法,这个方法包含了线程需要执行的任务。
  • 在主程序或其他地方创建该子类的实例。
  • 调用该实例的 start() 方法来启动新线程。
class MyThread extends Thread {
    @Override
    public void run() {
        // 这里是线程执行体
        System.out.println("Running in a new thread...");
    }
    
    public static void main(String[] args) {
        MyThread t = new MyThread();
        t.start();  // 启动新线程
    }
}
  1. 实现Runnable接口重写run()方法
  • 定义一个类,实现 java.lang.Runnable 接口,并重写 run() 方法。
  • 创建 Runnable 实现类的对象,并将其作为参数传入 Thread 类的构造函数来创建一个线程对象。
  • 调用这个线程对象的 start() 方法启动线程。
class MyRunnable implements Runnable {
    @Override
    public void run() {
        // 这里是线程执行体
        System.out.println("Running in a new thread using Runnable...");
    }
    
    public static void main(String[] args) {
        Runnable task = new MyRunnable();
        Thread t = new Thread(task);
        t.start();  // 启动新线程
    }
}
  1. 通过ExecutorServiceCallable接口:
  • 实现 java.util.concurrent.Callable 接口,重写 call() 方法,它可以有返回值并且可以抛出异常。
  • 使用 java.util.concurrent.ExecutorService 或相关工具类(如 ThreadPoolExecutor)来提交任务,并启动线程。
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

class MyCallable implements Callable<String> {
    @Override
    public String call() throws Exception {
        // 这里是线程执行体
        System.out.println("Running in a new thread using Callable...");
        return "Task result";
    }
    
    public static void main(String[] args) {
        ExecutorService executor = Executors.newSingleThreadExecutor();
        MyCallable myTask = new MyCallable();
        Future<String> future = executor.submit(myTask);
        // 关闭线程池(实际应用中应在适当的时候关闭)
        executor.shutdown();
    }
}

终止

  1. 线程自然终止
    要么是run执行完成了,要么是抛出一个未处理的异常导致线程提前结束。

  2. 通过共享标志位

  • 创建一个共享的volatile布尔变量作为退出标志,线程在运行过程中定期检查此标志。当该标志变为true时,线程主动退出执行循环或者清理资源后结束。
class StoppableThread extends Thread {
    private volatile boolean isRunning = true;

    public void stopThread() {
        isRunning = false;
    }

    @Override
    public void run() {
        while (isRunning) {
            // 执行线程逻辑
            // ...
            // 若isRunning变为false,则线程将在此处退出循环
        }
        // 清理资源或做一些收尾工作
    }
}
  1. 使用 interrupt() 方法
  • interrupt() 方法不会立即停止线程,而是向线程发送一个中断请求。线程本身需要捕获并处理这个中断请求,通常是通过检查 Thread.currentThread().isInterrupted() 来判断线程是否被中断,或者在阻塞操作(如 sleep()wait()join(), 或者读写同步流等)中捕获 InterruptedException 异常来响应中断。
class InterruptibleThread extends Thread {
    @Override
    public void run() {
        try {
            while (!Thread.currentThread().isInterrupted()) {
                // 执行线程逻辑
                // ...
            }
        } catch (InterruptedException e) {
            // 处理中断异常,通常在这里进行清理工作
            Thread.currentThread().interrupt(); // 重新设置中断标志,保持中断状态
        }
    }

    public void stopThread() {
        this.interrupt(); // 发送中断请求
    }
}
  1. 响应中断的阻塞方法
  • 当线程在某个阻塞方法中时,调用 interrupt() 可能会使该方法抛出 InterruptedException,从而跳出阻塞状态。
class BlockedThread extends Thread {
    @Override
    public void run() {
        try {
            Thread.sleep(10000); // 假设这是一个长时间的阻塞操作
        } catch (InterruptedException e) {
            // 捕获到中断异常后,线程应根据需要清理资源并退出
            System.out.println("Thread interrupted, exiting.");
            return;
        }
    }
    
    public void stopThread() {
        this.interrupt();
    }
}
  1. 避免使用过期方法 stop() 和 destroy()
  • Java早期版本中的 stop()suspend(), 和 resume() 方法由于可能导致数据不一致和死锁等问题已经被弃用。因此,不应该在现代Java编程中使用这些方法来终止线程。
  1. 区别
  • 共享标志位

    • 优点:简单直观,适用于那些在循环中执行任务并且可以快速检查退出条件的线程。这种方式允许线程在完成当前循环迭代或关键操作后安全地退出。
    • 缺点:若线程正在长时间阻塞操作中,单纯依靠共享标志位可能无法立即响应退出请求。此外,必须保证线程能够及时并正确地检查共享标志,否则可能导致线程无法按预期退出。线程必须从阻塞调用返回后, 才会检查这个取消标志。
  • interrupt() 方法:

    • 优点:更加灵活,能够在各种阻塞状态下有效地中断线程,如在等待IO操作、锁或者在 sleep() 中时,调用 interrupt() 会抛出 InterruptedException,线程可以捕获异常并作出相应处理。
    • 缺点:需要线程代码支持中断模式,即在合适的地方检查中断状态或处理中断异常。如果不妥善处理,仍可能导致资源未释放或其他问题。

综合来看,使用 interrupt() 方法配合是更为推荐的选择,尤其是对于复杂的多线程环境。这是因为这种方式既能通知线程尽早中断执行,又能确保线程在收到中断请求后有机会进行资源清理和状态恢复,提高了程序的安全性和可靠性。

线程的状态/生命周期

  1. 新建(New):
  • 当线程对象刚刚被创建,但是还没有调用 start() 方法时,线程处于新建状态。在这个状态下,线程对象已经存在,但并没有分配到任何系统资源。
  1. 就绪(Runnable/Ready) :
  • 当线程调用了 start() 方法之后,线程进入就绪状态。此时线程已准备好执行,等待操作系统的调度。一旦操作系统给予CPU时间片,线程就会转到运行状态。
  1. 运行(Running) :
  • 线程获得了CPU时间片开始执行时,它处于运行状态。在一个单核CPU环境中,一次只有一个线程在运行,而在多核CPU环境下,可能有多个线程同时处于运行状态。
  1. 阻塞(Blocked/Waiting/Sleeping) :
  • 表示线程阻塞于,等待某个同步对象(如锁)的释放(Blocked/Synchronized Block)。
  1. 等待(Waiting) :
  • 进入该状态的线程需要等待其他线程做出一些特定动作 (通知或中断)
  1. 超时等待(Timed Waiting) :
  • 该状态不同于 WAITING,它可以在指定的时间后自行返回
  1. 终止(Terminated/Dead) :
  • 线程完成了它的任务,或者被提前强制中断后,线程进入终止状态。线程一旦终止,就不能再次变为其他状态,也不会再参与调度执行。

状态变化图 image.png

线程优先级

线程优先级是在多线程环境下,操作系统用于确定哪个线程应该获取CPU时间片并执行的一种机制。在Java中,线程优先级是一个整数值,范围通常是从1(Thread.MIN_PRIORITY)到10(Thread.MAX_PRIORITY),默认优先级为5(Thread.NORM_PRIORITY)

Java提供了设置线程优先级的方法,如 setPriority(int priority),但这并不意味着优先级更高的线程一定会比优先级较低的线程先得到执行机会。Java线程的优先级只是给操作系统的建议,具体线程调度策略由操作系统决定,不同的操作系统对线程优先级的处理方式可能存在差异。

因此,在编写多线程程序时,虽然可以调整线程优先级,但不应过度依赖优先级来控制线程的执行顺序,尤其是在涉及同步和共享资源访问的情况下。更多时候,应当通过合理的线程同步机制(如synchronized关键字、Lock接口、Condition接口等)、线程通信机制(如wait-notify、CountDownLatch、Semaphore等)以及并发容器等手段来设计和优化多线程程序。

线程的调度

线程调度是指系统为线程分配 CPU 使用权的过程,主要调度方式有两种: 协同式线程调度(Cooperati ve Threads-Scheduling)抢占式线程调度(Preempti ve Threads-Scheduling)

使用协同式线程调度的多线程系统, 线程执行的时间由线程本身来控制, 线 程把自己的工作执行完之后, 要主动通知系统切换到另外一个线程上。使用协同 式线程调度的最大好处是实现简单,由于线程要把自己的事情做完后才会通知系 统进行线程切换, 所以没有线程同步的问题, 但是坏处也很明显, 如果一个线程 出了问题,则程序就会一直阻塞。

使用抢占式线程调度的多线程系统, 每个线程执行的时间以及是否切换都由 系统决定。在这种情况下, 线程的执行时间不可控, 所以不会有「一个线程导致 整个进程阻塞」的问题出现。Java 线程调度就是抢占式调度。

守护线程

Daemon(守护) 线程是一种支持型线程,因为它主要被用作程序中后台调 度以及支持性工作。这意味着,当一个 Java 虚拟机中不存在非 Daemon 线程的 时候, Java 虚拟机将会退出。可以通过调用 Thread.setDaemon(true)将线程设置 为 Daemon 线程。我们一般用不上,比如垃圾回收线程就是Daemon 线程。

Daemon 线程被用作完成支持性工作, 但是在 Java 虚拟机退出时 Daemon 线 程中的 finally 块并不一定会执行。在构建 Daemon 线程时, 不能依靠 finally 块中 的内容来确保执行关闭或清理资源的逻辑。

3. 线程间的通信、协调和协作

管道输入输出流

Java线程中的管道输入输出流是一种特殊的流,它主要用于线程间的通信。管道流允许一个线程写入数据,而另一个线程从同一个管道读取数据,以此来实现在两个线程之间传输数据的目的。

在Java中,管道流由以下两个类构成:

  1. PipedOutputStream / PipedWriter: 这两个类分别表示字节管道输出流和字符管道输出流,它们用于向管道中写入数据。一个线程可以通过此类将数据放入管道中。
  2. PipedInputStream / PipedReader: 这两个类分别表示字节管道输入流和字符管道输入流,它们用于从管道中读取数据。另一个线程可以从这类对象中取出先前写入的数据。

为了正确使用管道流,通常的做法是首先创建一对管道输入流和管道输出流对象,然后在线程A中绑定输出流,线程B中绑定输入流。线程A通过输出流写入数据,这些数据会被保存在内存中的管道中,然后线程B可以通过输入流从管道中读取这些数据。

由于管道的性质,数据的传输是单向的,如果需要双向通信,则需要创建两对管道流。

管道流的使用涉及到线程同步问题,因为写入和读取操作必须协调进行,以避免出现数据丢失或阻塞问题。Java内部对管道流进行了必要的同步处理,以确保线程安全。然而,在使用时仍需注意,读取线程和写入线程需要正确地启动和管理,以防止可能出现的死锁情况。

public class PipeExample {
    public static void main(String[] args) {
        PipedInputStream pis = new PipedInputStream();
        PipedOutputStream pos = new PipedOutputStream();

        try {
            // 将输出流连接到输入流
            pis.connect(pos);

            // 创建两个线程,一个负责写入数据,一个负责读取数据
            Thread writerThread = new Thread(new Writer(pos));
            Thread readerThread = new Thread(new Reader(pis));

            // 启动线程
            writerThread.start();
            readerThread.start();

            // 等待线程执行完毕
            writerThread.join();
            readerThread.join();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    static class Writer implements Runnable {
        private final PipedOutputStream pos;

        public Writer(PipedOutputStream pos) {
            this.pos = pos;
        }

        @Override
        public void run() {
            try {
                pos.write("Hello from writer!".getBytes());
                pos.flush();
                pos.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    static class Reader implements Runnable {
        private final PipedInputStream pis;

        public Reader(PipedInputStream pis) {
            this.pis = pis;
        }

        @Override
        public void run() {
            byte[] buffer = new byte[1024];
            int len;

            try {
                while ((len = pis.read(buffer)) != -1) {
                    String message = new String(buffer, 0, len);
                    System.out.println("Reader received: " + message);
                }
                pis.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

join方法

Java线程的 join() 方法是Java多线程编程中用于同步线程执行的关键方法之一。它属于 java.lang.Thread 类的一部分,允许一个线程等待另一个线程完成其任务后再继续执行。

join()方法的工作原理

当一个线程调用另一个线程的 join() 方法时,调用线程会阻塞(暂停执行),直到被调用 join() 方法的那个线程(称为“目标线程”)完成执行,即目标线程的 run() 方法执行完毕,或者调用带有超时参数的 join(long millis) 方法指定的时间已过。

  • void join():无参版本的 join() 方法会让当前线程一直等待,直到目标线程执行完成。
  • void join(long millis):带有超时参数的版本会在等待目标线程指定的毫秒数后,无论目标线程是否完成,当前线程都会继续执行。
  • void join(long millis, int nanos):更加精确的超时版本,除了毫秒数外还提供纳秒精度的等待时间。

应用场景

join() 方法经常被用于这样的场景:

  • 主线程需要等待所有子线程完成后再继续执行。
  • 确保某个重要操作在其他线程执行完毕后才能继续进行,以维持正确的执行顺序和逻辑完整性。
  • 在测试或调试时,模拟顺序执行的效果,观察线程执行的先后顺序和结果。
class WorkerThread extends Thread {
    @Override
    public void run() {
        // 执行耗时操作...
    }
}

public class Main {
    public static void main(String[] args) throws InterruptedException {
        WorkerThread worker = new WorkerThread();
        worker.start();

        // 主线程等待worker线程完成
        worker.join();

        // worker线程执行完成后,主线程继续执行
        System.out.println("Worker thread has finished its job");
    }
}

注意事项

  • 调用 join() 方法可能会抛出 InterruptedException 异常,这是因为在等待期间,如果当前线程被中断,那么 join() 方法会抛出此异常。
  • 使用 join() 方法时要注意避免死锁,确保线程能够适时退出等待状态。
  • 应谨慎使用 join() 以避免线程饥饿问题,即一个线程持续等待其他线程而导致无法执行。在设计多线程程序时,应结合适当的同步机制,如锁、条件变量等来协调线程之间的执行顺序和资源共享。

synchronized内置锁

Java中的synchronized关键字是用来实现线程同步的一种内置锁机制,它提供了对共享资源访问的互斥控制,确保在同一时刻,只有一个线程能够访问被synchronized修饰的代码块或方法,从而防止多线程环境下因并发访问而导致的数据不一致问题。

基本用法: synchronized有两种用法:

  1. 同步方法

    • synchronized关键字用于方法声明时,整个方法被视为同步代码块,对该方法的访问将自动获得相应的锁。
      public class MyClass {
          private int sharedResource;
      
          public synchronized void accessSharedResource() {
              // 在这里修改sharedResource变量是线程安全的
              // 因为此方法已被synchronized修饰,同一时间只有一个线程可以执行此方法
          }
      }
      
      如果是实例方法,锁对象是当前对象实例(this);如果是静态方法,锁对象则是类的Class对象。
  2. 同步代码块

    • synchronized关键字放在代码块前时,可以指定具体的对象作为锁,只有获得该对象锁的线程才能进入同步代码块。

      public class MyClass {
          private Object lockObject = new Object();
      
          public void accessSharedResource() {
              synchronized (lockObject) {
                  // 在这里修改sharedResource或其他共享资源是线程安全的
                  // 因为此代码块已被synchronized锁定,且锁定了特定对象lockObject
              }
          }
      }
      

特点

  • 互斥性:当一个线程持有synchronized锁时,其他试图获取同样锁的线程将被阻塞,直到锁被释放。
  • 可见性:synchronized确保了对监视器对象(锁对象)的修改对其他线程是可见的,即当一个线程在同步代码块中修改了共享变量后,其他线程随后可以看到这个修改。
  • 有序性:由于Java内存模型的happens-before规则,synchronized也确保了对锁的获取和释放操作具有一定的有序性,从而保证了线程间的操作顺序。

注意事项

  • 使用synchronized时应注意避免死锁,确保在获取锁之后能够正确释放。
  • 由于synchronized是独占锁,过多使用可能会影响并发性能,特别是在读多写少的场景下,可考虑使用读写锁(java.util.concurrent.locks.ReentrantReadWriteLock)等其他并发控制工具。
  • synchronized无法控制线程的中断,如果需要中断等待中的线程,通常结合使用Thread.interrupt()InterruptedException
  • synchronized锁是非公平锁,默认情况下线程获取锁的顺序不一定按照等待队列的先进先出原则,但从Java 1.5开始可以通过java.util.concurrent.locks.Lock接口提供的ReentrantLock等锁类实现公平锁。

volatile 最轻量的通信/同步机制

在Java中,volatile关键字应用于共享变量,主要是为了让线程之间的可见性和有序性得到保证。当一个变量被声明为volatile时,编译器和JVM会遵循以下原则:

  1. 可见性

    • 当一个线程修改了volatile变量的值时,其他线程可以立即看到这个更新。这是因为JVM通过内存屏障来实现,强制刷新缓存并将新值写回到主内存,同时使得其他线程在读取该变量时,会直接从主内存加载最新值,而非本地缓存。
  2. 有序性

    • volatile变量的读/写操作都会插入内存屏障(memory barriers),防止处理器重排序(instruction reordering),从而确保在多线程环境下,对volatile变量的操作不会与其他普通变量的操作交错执行。

用法

public class VolatileExample {
    private volatile boolean flag = false;

    public void setFlagTrue() {
        flag = true;
    }

    public boolean checkFlagAndDoSomething() {
        if (flag) {
            // 当flag变成true时,这段代码将会被执行
            // 注意,此处的逻辑应尽可能简洁,避免不必要的复杂性
            // 且不应依赖于外部状态的变化
            // volatile只保证了对flag本身的可见性,不保证其他共享变量的同步
            // 对于复杂的同步逻辑,通常需要配合锁来实现
        }
        return flag;
    }
}

volatile的应用场景:

  • 状态标志:如上例所示,当多个线程需要根据某个共享变量的值改变其行为时,可以使用volatile变量来标记状态的变更,例如,指示服务是否已停止、线程是否需要退出等。
  • double-checked locking优化:在单例模式实现中,有时会结合volatile和双重检查锁定(Double-Checked Locking Pattern)来确保单例实例的延迟初始化和线程安全性。
  • 低开销状态同步:对于简单的状态同步,volatile提供了一种较轻量级的解决方案,尤其适合读多写少的情况,因为它不需要像synchronized那样消耗较大的锁资源。

为何说是“最轻量的通信/同步机制”:

相比于synchronized关键字提供的互斥锁机制,volatile关键字引入的同步开销较小。主要体现在:

  • 锁的开销synchronized会加锁和解锁,可能导致线程上下文切换,增加系统开销。而volatile没有锁的获取和释放动作,只要求内存操作的顺序性和可见性,因此在一定程度上减少了同步开销。
  • 并发性能:在仅仅需要保证可见性和有序性的场景下,volatile的性能优于使用锁,因为它避免了线程间的阻塞和唤醒。

然而,volatile也有其局限性,它不能替代锁解决所有同步问题,例如:

  • 原子性:volatile不能保证复合操作的原子性,如i++不是原子操作,即使i是volatile类型的,也需要借助原子类或synchronized来实现。
  • 同步块:volatile不能替代synchronized用于保护一段代码块,即不能确保同一时刻只有一个线程执行某段代码。

总的来说,volatile作为一种轻量级的同步机制,适合于读多写少、状态变更简单、不需要保证原子性操作的场景。但对于需要复杂同步逻辑的情况,仍然需要依赖synchronized、Lock API或其他高级并发工具。

线程等待/通知机制

Java线程等待/通知机制是Java并发编程中的一种重要同步机制,它基于对象监视器(Monitor)的概念,通过对象的wait()notify()notifyAll()三个方法实现线程间的通信和同步。

实现原理:

  1. 对象监视器(Monitor) : 在Java中,每个对象都有一个与之关联的监视器锁,当线程通过synchronized关键字进入同步代码块或同步方法时,会自动获取对象的监视器锁。这个监视器锁实质上是一种互斥机制,同一时间内只有一个线程可以获得对象的监视器锁。

  2. 等待/通知机制

    • wait() :当线程调用对象的wait()方法时,当前线程会释放对象监视器锁并进入等待队列(WAITING状态),直到其他线程调用同一线程对象上的notify()notifyAll()方法唤醒它。被唤醒后,线程需要重新获取对象监视器锁才能继续执行。
    • notify() :唤醒在该对象监视器上等待的单个线程,如果有多个线程在等待,则会选择其中一个线程唤醒。但不能指定具体唤醒哪一个线程,选择标准是操作系统决定的。
    • notifyAll() :唤醒在该对象监视器上等待的所有线程,被唤醒的线程都会变为就绪状态(READY状态),但并不会立即获得对象锁,需要等待锁被释放后重新竞争。

应用场景:

等待/通知机制在生产者-消费者模式、数据库连接池、线程池、缓存更新等多个场景中广泛应用。

例如,在生产者-消费者模型中,生产者线程在生产完产品后,通过调用缓冲区对象的notify()notifyAll()方法通知消费者线程消费,而消费者线程在缓冲区为空时调用wait()方法等待新的产品。

public class Buffer {
    private final int MAX_SIZE = 10;
    private int count = 0;
    private final List<Object> data = new ArrayList<>();

    public synchronized void put(Object item) throws InterruptedException {
        while (count == MAX_SIZE) {
            wait(); // 缓冲区满,生产者线程等待
        }
        data.add(item);
        count++;
        notifyAll(); // 生产者线程添加完商品,唤醒消费者线程
    }

    public synchronized Object take() throws InterruptedException {
        while (count == 0) {
            wait(); // 缓冲区空,消费者线程等待
        }
        Object item = data.remove(0);
        count--;
        notifyAll(); // 消费者线程消费完商品,唤醒生产者线程
        return item;
    }
}

等待和通知的标准范式

等待方遵循如下原则。
1)获取对象的锁。
2)如果条件不满足, 那么调用对象的 wait()方法, 被通知后仍要检查条件。
3)条件满足则执行对应的逻辑。

1713275062683.png
通知方遵循如下原则。
1)获得对象的锁。
2)改变条件。
3)通知所有等待在对象上的线程。

image.png