多线程学习

一. 认识线程(Thread)

1. 线程是什么

定义:线程是一个轻量级的执行流,它代表了程序执行的一个路径。每个线程都有自己的程序计数器、栈和局部变量,但线程之间可以共享同一个进程的全局变量和堆。

主线程:在 Java 程序中,main() 方法所运行的线程被称为主线程(Main Thread)。当你启动一个 Java 应用程序时,JVM 会创建一个主线程来执行 main() 方法。

执行流:每个线程可以看作是一条独立的执行路径,多个线程可以并行执行同一段代码,但它们的执行顺序是非确定性的,这意味着可能会发生交错执行(interleaving)。

2. 为啥要有线程

并发编程的需求:现代计算需求往往需要同时执行多个任务,尤其在多核处理器环境下。并发编程能够充分利用 CPU 的资源,提升程序性能和响应速度。

提高算力:多核 CPU 的发展使得并发编程成为必要。通过创建多个线程,可以将计算任务分配到多个 CPU 核心上,提高执行效率。

等待 I/O 的场景:许多程序在执行过程中会等待输入/输出(I/O)操作,如文件读取、网络请求等。使用多线程可以在等待 I/O 时,让其他线程继续执行,从而提高资源的利用率。

线程相对进程的优势

  • 创建速度:线程的创建和销毁比进程更快,因为线程的上下文切换比进程的上下文切换开销小。
  • 调度效率:操作系统调度线程的效率高于进程,线程在同一进程内的切换非常迅速。
  • 资源共享:同一进程内的线程共享内存空间,这使得数据传递更加高效。

线程池与协程

  • 线程池:为了进一步提高性能,Java 提供了线程池机制,允许预先创建一组线程,重复使用,减少频繁创建和销毁线程的开销。
  • 协程:协程是一种更轻量级的线程实现,它允许在单线程内实现并发。协程通过协作式调度管理执行状态,使得资源利用更加高效。

3. 进程和线程的区别

3.1. 基本概念
  • 进程(Process)
    • 进程是操作系统分配资源的基本单位,它是一个执行中的程序。每个进程拥有自己的地址空间、数据段、堆栈和其他属性。
  • 线程(Thread)
    • 线程是进程内部的一个执行单元,它是操作系统能够独立调度的最小单位。每个进程至少有一个主线程,多个线程可以并行执行。
3.2. 资源分配
  • 进程
    • 每个进程都有独立的内存空间,包括代码段、数据段和堆栈。进程间的内存是相互隔离的。
  • 线程
    • 同一进程中的所有线程共享进程的内存空间,包括全局变量和堆。这使得线程之间可以快速通信,但也带来了线程安全问题。
3.3. 数据共享与通信
  • 进程
    • 进程间不能直接共享数据,数据共享需要通过进程间通信(IPC)机制,如管道、消息队列、共享内存、信号量等。这使得进程间的数据交换相对复杂。
  • 线程
    • 线程可以直接访问同一进程的内存,数据共享非常简单,可以通过直接读写共享变量实现。这种共享能力虽然方便,但也可能导致数据竞争和一致性问题。
3.4. 创建与销毁
  • 进程
    • 创建和销毁进程的开销较大。创建进程时,操作系统需要分配新的内存空间和系统资源,这会消耗更多的时间和资源。
  • 线程
    • 线程的创建和销毁相对更快,因为线程共享进程的资源和内存。创建新线程只需分配少量的系统资源(如线程栈和寄存器)。
3.5. 调度与执行
  • 进程
    • 进程是操作系统调度的基本单位。进程的上下文切换涉及到更复杂的操作,如保存和恢复进程的完整状态,涉及大量的资源管理。
  • 线程
    • 线程是最小的执行单位,操作系统调度线程的开销较小。线程的上下文切换速度比进程快,因为线程共享相同的地址空间,保存和恢复的状态较少。
3.6. 崩溃影响
  • 进程
    • 如果一个进程崩溃,通常不会影响到其他进程的运行。操作系统能够保证进程之间的隔离性。
  • 线程
    • 如果一个线程崩溃,可能会导致整个进程崩溃。这是因为线程之间共享内存,崩溃的线程可能会破坏共享数据,从而影响其他线程的执行。
3.7. 应用场景
  • 进程
    • 适用于需要高隔离性的应用,如不同的服务或应用程序之间的运行(如浏览器、文本编辑器、播放器等),各个进程独立且不干扰。
  • 线程
    • 适用于需要高并发的场景,如服务器处理多个客户端请求时,使用线程可以提高响应速度和资源利用率。
3.8. 小结
特征进程线程
定义资源分配的基本单位最小的执行单元
内存空间独立的地址空间共享同一进程的内存空间
数据共享通过 IPC 机制直接访问共享内存
创建与销毁开销较大开销较小
调度系统调度的基本单位轻量级的调度单位
崩溃影响不影响其他进程可能导致整个进程崩溃
应用场景高隔离性需求(如独立服务)高并发性需求(如 web 服务器处理请求)

这种区别能帮助你全面理解进程和线程之间的不同。如果还有其他方面需要进一步探讨或具体的例子,请告诉我!

4. Java的线程和操作系统线程的关系

操作系统的支持

  • 线程是操作系统的基本概念,操作系统内核通过提供线程的创建、调度、同步等功能来支持多线程编程。
  • 不同的操作系统使用不同的机制来管理线程,例如,Linux 使用 pthread 库来提供线程功能。

Java 的线程模型

  • Java 的 Thread 类是对操作系统线程 API 的抽象,JVM 在底层将 Java 线程映射为操作系统线程。
  • Java 线程通常是用户级线程,JVM 负责调度这些线程,并将它们映射到操作系统的线程上。

线程调度

  • Java 线程的调度依赖于操作系统的调度策略,通常是时间片轮转(Round Robin)或优先级调度(Priority Scheduling)。
  • 在 Java 中,开发者可以通过 Thread 类设置线程的优先级,但实际效果还依赖于操作系统的实现。

二 Java 中创建线程的方式总结

1.继承 Thread 类

  • 描述:创建一个类继承 Thread,重写 run() 方法,使用 start() 方法启动线程。

  • 优点

    • 代码简单,易于理解。
    • 可以直接调用 Thread 类中的方法。
  • 缺点

    • Java 是单继承的,不能继承其他类。
  • 示例

    class MyThread extends Thread {
        public void run() {
            // 线程执行的代码
        }
    }
    

2.实现 Runnable 接口

  • 描述:创建一个类实现 Runnable 接口,重写 run() 方法,并将 Runnable 实例传递给 Thread 对象。

  • 优点

    • 支持多重继承,可以同时继承其他类。
    • 适合多个线程共享同一任务。
  • 缺点

    • 代码相对复杂。
  • 示例

    class MyRunnable implements Runnable {
        public void run() {
            // 线程执行的代码
        }
    }
    

3.使用 Callable 接口

  • 描述:创建一个类实现 Callable 接口,重写 call() 方法,使用 FutureTaskExecutorService 执行任务。

  • 优点

    • 可以返回结果并处理异常。
    • 更适合需要计算结果的场景。
  • 缺点

    • 需要使用 FutureTask,相对较复杂。
  • 示例

    class MyCallable implements Callable<String> {
        public String call() throws Exception {
            // 线程执行的代码
            return "结果";
        }
    }
    

4.使用 Lambda 表达式(Java 8 及以上)

  • 描述:使用 Lambda 表达式简化 RunnableCallable 的实现。

  • 优点

    • 代码简洁,减少了样板代码。
    • 更容易阅读和维护。
  • 示例

    Thread thread = new Thread(() -> {
        // 线程执行的代码
    });
    

5.小结

创建方式描述优点缺点
继承 Thread直接创建线程类并重写 run() 方法简单易懂,直接调用 Thread 方法单继承限制,无法继承其他类
实现 Runnable 接口实现接口并重写 run() 方法支持多重继承,适合共享任务代码较复杂
实现 Callable 接口实现接口并重写 call() 方法可以返回结果,处理异常相对复杂,需要使用 FutureTask
使用 Lambda 表达式使用 Lambda 简化代码代码简洁,易读仅适用于 Java 8 及以上

以上总结涵盖了 Java 中创建线程的主要方法及其优缺点,可以根据具体的应用场景选择最适合的方式。如果还有其他问题或需要进一步讨论的内容,请随时告诉我!

三. 线程的生命周期

线程在 Java 中的生命周期主要包括以下七种状态:

1.新建(New)

  • 描述:当线程对象被创建时,它处于新建状态。此时,线程尚未开始执行,且没有占用系统资源。

  • 示例:

    Thread thread = new Thread(() -> {
        // 线程执行的代码
    });
    // 此时线程处于新建状态
    

2.可运行(Runnable)

  • 描述:当调用 start() 方法后,线程进入可运行状态。可运行状态的线程可能是就绪(READY)或正在运行(RUNNING)。可运行状态的线程处于系统的可运行线程池中,等待 CPU 的调度。

    • 就绪(READY):线程已准备好,等待 CPU 时间片。
    • 运行中(RUNNING):线程获得 CPU 时间片,正在执行代码。
  • 示例:

    thread.start();  // 调用 start() 方法,线程进入可运行状态
    

3.运行(Running)

  • 描述:当线程获得 CPU 资源后,进入运行状态,开始执行 run() 方法中的代码。只有一个线程可以在任意时刻处于运行状态。

  • 示例:

    // 在这里,线程正在执行具体的任务
    

4.阻塞(Blocked):

  • 描述:线程在等待某个资源(如获取锁)而被挂起时进入阻塞状态。阻塞通常发生在多线程环境中,当一个线程试图访问一个已经被其他线程占用的资源时,它会被阻塞,无法继续执行。

  • 示例:

    synchronized (someObject) {
        // 这里是访问被锁定的资源
    }
    // 如果另一个线程已经获取了 someObject 的锁,当前线程将被阻塞
    

5.等待(Waiting)

  • 描述:线程进入等待状态时,它需要等待其他线程的特定动作(如通知或中断)。在等待状态下,线程不会占用 CPU 资源。

  • 示例:

    synchronized (someObject) {
        someObject.wait();  // 线程在这里等待
    }
    

6.超时等待(Timed Waiting)

  • 描述:线程在此状态时,会在指定的时间后自动返回。这种状态通常用于等待一定时间。

  • 示例:

    synchronized (someObject) {
        someObject.wait(1000);  // 等待最多 1000 毫秒
    }
    

7.终止(Terminated)

  • 描述:线程的执行完成或因异常中断后,进入终止状态。此时,线程生命周期结束,无法再被启动。

  • 示例:

    // run() 方法执行完毕,线程进入终止状态
    

8.线程状态的转换

  • 新建到可运行:当调用 start() 方法时,线程从新建状态转变为可运行状态。
  • 可运行到运行:当线程调度器分配 CPU 资源时,线程从可运行状态转变为运行状态。
  • 运行到阻塞:当线程尝试获取锁而未成功时,或者执行了 sleep()wait() 等方法时,线程将被阻塞。
  • 阻塞到可运行:当所等待的资源可用时,阻塞的线程会返回到可运行状态,等待再次获得 CPU 资源。
  • 运行到等待:线程在调用 wait()join() 等方法时,进入等待状态。
  • 等待到可运行:当其他线程调用了相应的通知方法(如 notify()notifyAll())时,等待的线程会返回到可运行状态。
  • 超时等待到可运行:当指定的等待时间到达时,线程会自动返回到可运行状态。
  • 运行到终止:线程的 run() 方法执行完毕或抛出未捕获异常时,线程进入终止状态。

流转图如下:

在这里插入图片描述

9.线程状态的查询

可以使用 Thread.getState() 方法来查看线程的当前状态。

四 多线程带来的风险 - 线程安全

1. 线程安全的概念

  • 定义:在多线程环境中,确保多个线程对共享资源的访问是安全的,使得程序的行为是可预测的,且数据始终保持一致。
  • 重要性:
    • 防止数据不一致。
    • 保护共享资源,确保程序逻辑的正确执行。

2. 线程安全问题的来源

  • 数据竞争(Race Condition)
    • 多个线程同时访问和修改共享变量。
    • 示例:两个线程同时递增同一个计数器,导致最终结果不正确。
  • 脏读(Dirty Read)
    • 一个线程读取到尚未提交的值。
    • 结果:可能基于错误的数据进行操作,导致程序状态异常。
  • 死锁(Deadlock)
    • 两个或多个线程互相等待对方释放锁,导致程序无法继续执行。
    • 示例:线程A持有资源1的锁并等待资源2的锁,而线程B则相反,形成循环等待。
  • 活锁(Livelock)
    • 线程不断变更状态以避免死锁,但由于状态调整没有实际工作,导致系统无法进展。

3. 线程同步的必要性

为了避免上述问题,线程间必须进行有效的同步,确保同一时刻只有一个线程可以操作共享资源。

a. 使用 synchronized
  • 同步方法:

    • 在方法声明中使用 synchronized 关键字,确保同一时间只有一个线程能执行该方法。
    • 适合简单场景,易于使用。
  • 同步代码块:

    • 只锁住特定的代码段,适用于复杂的逻辑或对性能有较高要求的场景。

    • 语法示例:

      public void increment() {
          synchronized(this) {
              count++;
          }
      }
      
b. 使用 Lock 接口
  • 特点
    • 提供更灵活的锁机制,如可重入锁、尝试锁等。
    • 适合高并发场景,能够减少锁竞争。
  • 常用实现ReentrantLock
    • 可重入性:同一线程可以多次获得同一个锁。
    • 尝试获取锁:tryLock() 方法允许线程在获取锁时不被阻塞。
c. 使用 ReadWriteLock
  • 定义:提供读锁和写锁,读锁允许多个线程同时读取,写锁独占。
  • 使用场景:适合读操作占多数的情况,例如缓存读取,能有效提高并发性能。
d. 使用 Semaphore
  • 定义:信号量用于控制同时访问特定资源的线程数量。
  • 应用:适合限流场景,比如限制数据库连接的最大数量。
e. 使用 CountDownLatch
  • 定义CountDownLatch 是一个同步辅助类,用于让一个或多个线程等待直到一组操作完成。
  • 基本原理
    • CountDownLatch 维护一个计数器,线程可以通过调用 countDown() 方法来减少计数,其他线程可以通过 await() 方法等待计数器归零。
  • 使用场景
    • 适合于等待多个线程完成初始化或其他操作的场景。例如,在进行多线程并发处理时,主线程可以等待所有工作线程完成后再继续执行。
f. 使用 join 方法
  • 定义join 方法用于让调用该方法的线程等待其他线程完成执行。
  • 基本用法:
    • 当一个线程调用另一个线程的 join() 方法时,调用线程会阻塞,直到被调用线程执行完成。
  • 使用场景:
    • 适合在需要确保某个线程完成任务后再执行后续逻辑的场合,例如在多个线程并行处理数据时,主线程需要等待所有子线程完成后再进行汇总或输出结果。

4. 线程通信的必要性

线程间有时需要交换信息或协调工作,这就是线程通信的必要性。

a. 使用 wait(), notify(), notifyAll()
  • wait()
    • 使当前线程进入等待状态,直到被其他线程通知。
    • 示例:消费者在没有可用数据时进入等待。
  • notify()
    • 唤醒一个正在等待的线程。
  • notifyAll()
    • 唤醒所有等待的线程,适用于需要通知多个线程的场景。
b. 使用 Condition
  • 定义:提供更灵活的等待/通知机制,通常与 Lock 接口结合使用。
  • 优势:支持多个等待队列,增强了线程协调能力。

5. 线程安全的设计模式

a. 生产者-消费者模式
  • 原理:生产者生成数据,消费者消费数据,通过阻塞队列实现安全通信。
  • 实现:使用 BlockingQueue,生产者在队列满时等待,消费者在队列空时等待。
b. 单例模式(线程安全实现)
  • 实现方法:双重检查锁定(Double-Checked Locking),确保单例实例在多线程环境下只被创建一次。

  • 代码示例:

    public class Singleton {
        private static volatile Singleton instance;
    
        private Singleton() {}
    
        public static Singleton getInstance() {
            if (instance == null) {
                synchronized (Singleton.class) {
                    if (instance == null) {
                        instance = new Singleton();
                    }
                }
            }
            return instance;
        }
    }
    

五. 线程池

1. 什么是线程池?

线程池是一种并发编程的设计模式,用于管理和复用多个线程。线程池会预先创建一定数量的线程,避免在高并发环境下频繁创建和销毁线程,从而减少资源消耗和提升性能。

2. 线程池的优势

  • 性能提升:线程的创建和销毁是昂贵的操作,线程池通过复用线程显著提高系统性能。
  • 资源控制:通过限制线程的数量,线程池可以有效防止资源耗尽。
  • 响应速度:线程池可以迅速响应新的任务请求,降低了任务的等待时间。
  • 灵活管理:线程池通过任务队列和多种任务处理策略,增强了任务的调度能力。

3. 线程池的核心组件

1. 核心线程数(corePoolSize)
  • 定义:线程池中始终保持的线程数量。
  • 作用:在任务提交时,如果当前线程数少于核心线程数,则会创建新线程执行任务。
2. 最大线程数(maximumPoolSize)
  • 定义:线程池中允许的最大线程数量。
  • 作用:限制线程池能创建的最大线程数量,防止系统资源过度消耗。
3. 空闲线程存活时间(keepAliveTime)
  • 定义:当线程数超过核心线程数时,多余的空闲线程在此时间内会被终止。
  • 作用:有助于释放资源,避免过多的空闲线程占用系统内存。
4. 时间单位(unit)
  • 定义:用于指定 keepAliveTime 的时间单位。
  • 常用单位SECONDS, MILLISECONDS, MINUTES, HOURS, DAYS
5. 任务队列(workQueue)
  • 定义:用于存放等待执行的任务的队列。
  • 常见队列类型:
    • ArrayBlockingQueue:有界队列,限制最大任务数,适合任务数量可预测的场景。
    • LinkedBlockingQueue:无界队列,适合任务数量不确定的情况,线程安全。
    • SynchronousQueue:不存储元素,每个插入操作都必须等待对应的删除操作,适合瞬时任务。
    • PriorityBlockingQueue:按照优先级处理任务,适合任务优先级差异较大的场景。
6. 拒绝策略(handler)
  • 定义:处理无法执行的任务的策略。
  • 常用策略:
    • AbortPolicy:抛出异常,任务无法执行。
    • CallerRunsPolicy:由调用线程处理任务,适合简单的任务重试。
    • DiscardPolicy:直接丢弃任务,不抛出异常。
    • DiscardOldestPolicy:丢弃队列中最旧的任务,优先处理新任务。
* 运行流程
  • 首先按核心线程数创建线程任务
  • 核心线程数满了。任务进入工作队列
  • 队列也满了 扩大核心线程数
  • 当核心线程数达到最大线程数
  • 并且工作队列也满了 触发拒绝策略
  • 工作队列分有界和无界的

4. 创建线程池的方式

在Java中,java.util.concurrent包提供了多种方式来创建线程池,其中使用Executors工厂类是最常见的方法。

1. 使用 Executors 创建线程池
  • 固定线程池:适用于处理固定数量的并发任务。

    ExecutorService fixedThreadPool = Executors.newFixedThreadPool(5); // 固定5个线程
    
  • 可缓存线程池:适合处理大量短期任务,线程会根据需要动态创建和回收。

    ExecutorService cachedThreadPool = Executors.newCachedThreadPool(); // 根据需求动态创建线程
    
  • 单线程池:适用于需要保证任务按顺序执行的场景。

    ExecutorService singleThreadPool = Executors.newSingleThreadExecutor(); // 仅有一个线程
    
  • 定时任务线程池:用于周期性或延迟执行的任务。

    ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(5); // 固定线程数的定时任务池
    
2. 使用 ThreadPoolExecutor 自定义线程池

除了使用Executors,我们还可以通过ThreadPoolExecutor类来创建更灵活的线程池:

ThreadPoolExecutor executor = new ThreadPoolExecutor(
    2, // 核心线程数
    5, // 最大线程数
    60, // 空闲线程存活时间(秒)
    TimeUnit.SECONDS, // 时间单位
    new ArrayBlockingQueue<>(10), // 任务队列
    new ThreadPoolExecutor.AbortPolicy() // 拒绝策略
);

5. 示例代码

以下是使用 ThreadPoolExecutor 创建一个线程池的示例代码:

import java.util.concurrent.*;

public class ThreadPoolExample {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(3); // 创建固定大小的线程池

        for (int i = 0; i < 10; i++) {
            final int taskId = i;
            executor.submit(() -> {
                System.out.println("正在执行任务:" + taskId + ",线程:"+Thread.currentThread().getName());
                try {
                    Thread.sleep(200); // 模拟任务执行时间
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }

        executor.shutdown(); // 关闭线程池
    }
}

6. 适用场景

  • 固定大小线程池:适用于任务量稳定且可预测的场景,例如文件处理、图片上传等。
  • 可缓存线程池:适合处理大量短期任务,可以动态创建和回收线程,适合高并发场景。
  • 单线程池:适用于需要保证任务按顺序执行的场景,例如日志处理、任务调度等。
  • 定时任务线程池:适合定期执行的任务调度,例如定时备份、定时清理等。

7. 常见问题与注意事项

  • 合理配置参数:核心线程数、最大线程数和任务队列的选择应根据应用的实际需求进行合理配置,以避免资源耗尽或过度消耗。
  • 线程池的关闭:在使用完线程池后,确保调用 shutdown()shutdownNow() 以释放资源,防止内存泄漏。
  • 监控线程池状态:定期检查线程池的状态(如活跃线程数、已完成任务数等)以确保系统运行正常,避免任务堆积。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值