type
status
date
slug
summary
tags
category
icon
password
catalog
sort

Java 多线程深度剖析与实战指南

一、前言

在 Java 的世界里,多线程编程一直是开发人员眼中既神秘又充满挑战的领域。从简单的线程创建与启动,到复杂的线程安全问题和性能优化,多线程编程贯穿了 Java 应用开发的始终。本文将深入浅出地剖析 Java 多线程的核心概念、原理和实战技巧。

二、多线程基础

(一)线程与进程

  1. 进程 进程是具有一定独立功能的程序关于某个数据集合的一次运行活动,是操作系统动态执行的基本单元。在操作系统中,进程既是基本的分配单元,也是基本的执行单元。例如,当你同时运行游戏和网易云音乐时,这两个程序分别对应着两个不同的进程。
  1. 线程 线程是进程中的一个实体,是 CPU 调度和分派的基本单位。一个进程可以包含多个线程,这些线程共享进程的资源,如内存地址空间、文件句柄等。线程是独立调度和执行的最小单位。比如在 IDEA 中编写代码时,语法检查和自动保存功能就是多线程操作的体现。

(二)线程的生命周期

Java 中的线程有 6 种状态,分别是新建(NEW)、运行(RUNNABLE)、阻塞(BLOCKED)、等待(WAITING)、计时等待(TIMED_WAITING)和终止(TERMINATED)。其中,新建状态表示线程已被创建但尚未启动;运行状态表示线程正在 JVM 中执行;阻塞状态指线程被阻塞,无法获取锁;等待状态是线程在等待其他线程的通知;计时等待状态类似于等待状态,但有一个等待时间限制;终止状态表示线程已执行完毕。

(三)并发与并行

  1. 并发 并发指的是多个线程共享同一资源,交替执行。例如在秒杀抢购活动中,多个用户同时对一个商品发起抢购请求,这些请求对应的线程就是并发执行的。
  1. 并行 并行则指多项工作同时执行,之后再汇总结果。比如泡方便面时,电水壶烧水和撕调料倒入桶中这两个操作就可以并行进行。

(四)wait 与 sleep 的区别

  1. waitwait()Object 类的方法,调用该方法的线程会释放锁,进入等待状态,直到其他线程调用 notify()notifyAll() 方法通知它。
  1. sleepsleep()Thread 类的静态方法,调用该方法的线程在指定时间内暂停执行,但不会释放锁。

三、JUC(Java Util Concurrency)概述

JUC 是 JDK 5 引入的高并发工具类的集合,位于 java.util.concurrent 包下,主要包括锁、并发集合、并发工具类等,为解决多线程编程中的各种问题提供了强大的支持。

四、锁机制

(一)synchronized

synchronized 是 Java 内置的关键字,用于实现同步操作。它可以修饰方法、代码块等,其核心原理是利用 Java 中的每一个对象都可以作为锁。例如,对于普通同步方法,锁是当前实例对象;对于静态同步方法,锁是当前类的 Class 对象;对于同步代码块,锁是括号里面配置的对象。以下是 synchronized 的一个案例:

(二)Lock 接口

Lock 接口是 JDK 5 引入的新的多线程锁,提供了比 synchronized 更加灵活的锁机制。ReentrantLockLock 接口的实现类,它支持可重入、可公平等多种锁机制。以下是 ReentrantLock 的一个案例:
synchronized 相比,Lock 有以下区别:
  1. synchronized 是 Java 内置关键字,在 JVM 层面,Lock 是个 Java 类。
  1. synchronized 无法判断是否获取锁的状态,Lock 可以判断是否获取到锁。
  1. synchronized 会自动释放锁,Lock 需在 finally 中手工释放锁,否则容易造成线程死锁。
  1. synchronized 的锁不可中断,Lock 的锁可中断。
  1. synchronized 的锁是非公平的,Lock 的锁可公平可非公平。

五、创建线程的方式

在 Java 中,创建线程主要有以下几种方式:
  1. 继承 Thread 类
  1. 实现 Runnable 接口
  1. 使用 Lambda 表达式
  1. 使用 FutureTask 类
  1. 使用自定义线程池

六、线程间通信

(一)线程通信的方式

线程间通信主要通过生产者 - 消费者模式和通知等待唤醒机制来实现。生产者 - 消费者模式是一种典型的多线程协作模式,生产者负责生产数据,消费者负责消费数据。通知等待唤醒机制则是通过 wait()notify()notifyAll() 等方法来实现线程之间的通信。

(二)多线程编程模板

多线程编程模板主要包括判断、干活和通知三个步骤。以下是一个基于 synchronized 实现的线程间通信的案例:
在 JDK 8 中,还可以使用 Condition 来实现线程间通信,它提供了比 wait()notify() 更加灵活的通信方式:

七、多线程锁

在多线程编程中,锁是实现线程安全的关键。以下是关于多线程锁的一些要点:
  1. 一个对象中的多个 synchronized 方法,某一时刻内只能有一个线程访问这些方法,因为锁的是当前对象 this
  1. 静态同步方法的锁是当前类的 Class 对象。
  1. 普通同步方法与静态同步方法之间互不影响,因为它们使用的锁不同。

八、JUC 之集合

(一)集合中的不安全类

  1. ListArrayList 在迭代时如果同时对其进行修改,会抛出 ConcurrentModificationException 异常。解决方法包括使用 VectorCollections.synchronizedListCopyOnWriteArrayList
  1. SetHashSet 是线程不安全的,可以使用 CopyOnWriteArraySet 来解决线程安全问题。
  1. MapHashMap 也是线程不安全的,ConcurrentHashMap 则提供了线程安全的解决方案。

(二)CopyOnWriteArrayList 原理

CopyOnWriteArrayList 是基于写时复制(CopyOnWrite)机制的线程安全集合。它的核心原理是,在对集合进行写操作(如添加、删除等)时,不会直接修改原集合,而是先创建一个原集合的副本,在副本上进行修改操作,修改完成后,再将原集合的引用指向新的副本。这样,在整个写操作过程中,读操作可以不受影响地在原集合上进行,从而实现了读写分离,保证了线程安全。以下是 CopyOnWriteArrayList 的部分源码:

九、Callable 接口与 FutureTask 类

(一)Callable 接口

  1. 简介Callable 接口是 Java 5 引入的,它与 Runnable 接口类似,但比 Runnable 更强大。Callable 接口允许线程执行的任务返回一个结果,并且可以抛出异常。它只有一个方法 call(),该方法定义了线程执行的任务,可以返回一个结果,并且可以抛出受检异常。
  1. 与 Runnable 接口的区别
      • 是否有返回值:Callablecall() 方法可以返回结果,而 Runnablerun() 方法没有返回值。
      • 是否抛异常:Callablecall() 方法可以抛出受检异常,而 Runnablerun() 方法不能抛出受检异常。
      • 落地方法不一样:Callable 的落地方法是 call(),而 Runnable 的落地方法是 run()

(二)FutureTask 类

  1. 简介FutureTask 是 Java 5 引入的类,它实现了 RunnableFuture 接口,代表一个异步计算的任务。它提供了对异步任务的执行和结果获取的支持。你可以将 FutureTask 提交给线程池执行,也可以直接启动一个线程来执行它。
  1. 原理 在主线程中需要执行比较耗时的操作时,但又不想阻塞主线程时,可以把这些作业交给 FutureTask 在后台完成。当主线程将来需要时,就可以通过 FutureTaskget() 方法获取后台作业的计算结果或者执行状态。get() 方法会阻塞,直到任务完成。以下是 FutureTask 的一个案例:

十、JUC 强大的辅助类

(一)CountDownLatch(减少计数)

  1. 原理CountDownLatch 是一个同步辅助类,它允许一个或多个线程等待其他线程完成操作。它主要有两个方法,await()countDown()。当一个或多个线程调用 await() 方法时,这些线程会阻塞。其他线程调用 countDown() 方法会将计数器减 1,当计数器的值变为 0 时,因 await() 方法阻塞的线程会被唤醒,继续执行。
  1. 案例实现

(二)CyclicBarrier(循环栅栏)

  1. 原理CyclicBarrier 是一个同步辅助类,它允许一组线程互相等待,直到到达一个共同的屏障点。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。线程进入屏障通过 CyclicBarrierawait() 方法。
  1. 案例实现

(三)Semaphore(信号灯)

  1. 原理Semaphore 是一个计数信号量,它可以控制同时访问特定资源的线程数量。在信号量上定义两种操作:acquire(获取)和 release(释放)。当一个线程调用 acquire 操作时,它要么通过成功获取信号量(信号量减 1),要么一直等下去,直到有线程释放信号量,或超时。release 操作实际上会将信号量的值加 1,然后唤醒等待的线程。信号量主要用于两个目的,一个是用于多个共享资源的互斥使用,另一个用于并发线程数的控制。
  1. 案例实现

(四)ReentrantReadWriteLock(读写锁)

  1. 实际案例 在缓存框架中,读写锁可以很好地控制缓存的读写操作。例如,当多个线程同时读取缓存时,可以允许多个线程同时进行读操作;而当有线程需要写入缓存时,只允许一个线程进行写操作,并且此时不允许其他线程进行读写操作。
  1. 案例实现

十一、BlockingQueue(阻塞队列)

(一)阻塞队列简介

阻塞队列是一种特殊的队列,当队列为空时,获取元素的线程会自动阻塞,直到有元素被添加到队列中;当队列已满时,添加元素的线列会自动阻塞,直到有元素被移出队列。Java 中的阻塞队列位于 java.util.concurrent.BlockingQueue 接口下,常见的实现类有 ArrayBlockingQueueLinkedBlockingQueuePriorityBlockingQueueDelayQueueSynchronousQueueLinkedTransferQueueLinkedBlockingDeque 等。

(二)核心方法介绍

阻塞队列的核心方法主要包括以下几类:
  1. 插入元素
      • offer(E e):将元素插入队列,如果队列已满,返回 false
      • offer(E e, long timeout, TimeUnit unit):将元素插入队列,在指定时间内等待插入成功,如果超时则返回 false
      • put(E e):将元素插入队列,如果队列已满,阻塞等待。
  1. 移除元素
      • poll():移除队列中的头元素,如果队列为空,返回 null
      • poll(long timeout, TimeUnit unit):移除队列中的头元素,在指定时间内等待移除成功,如果超时则返回 null
      • take():移除队列中的头元素,如果队列为空,阻塞等待。
  1. 获取元素
      • peek():获取队列中的头元素,但不移除它,如果队列为空,返回 null

十二、线程池

(一)线程池的优点

  1. 降低资源消耗 通过重复利用已创建的线程,降低线程创建和销毁造成的资源消耗。
  1. 提高响应速度 当任务到达时,任务可以不需要等待线程创建就能立即执行。
  1. 提高线程的可管理性 线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性。使用线程池可以进行统一的分配、调优和监控。

(二)线程池架构

Java 中的线程池是通过 Executor 框架实现的,该框架中用到了 Executor、Executors、ExecutorService、ThreadPoolExecutor 这几个类。以下是 JDK 默认的几种线程池创建方法:
  1. newFixedThreadPool(int nThreads) 创建一个固定大小的线程池,线程池中的线程数量固定,所有任务都被放入一个无界任务队列中。当线程池中的线程都在执行任务时,新的任务会在队列中等待。
  1. newSingleThreadExecutor() 创建一个单线程的线程池,该线程池只有一个线程,所有任务都被依次执行。
  1. newCachedThreadPool() 创建一个大小不固定的线程池,线程池的大小可以动态增加。当线程空闲时间超过 60 秒时,线程会被自动回收。

(三)ThreadPoolExecutor 底层原理

ThreadPoolExecutor 是 Java 中线程池的核心实现类,它通过一组参数来控制线程池的行为。以下是 ThreadPoolExecutor 的构造方法:
其主要参数包括:
  1. corePoolSize :线程池中的常驻核心线程数。
  1. maximumPoolSize :线程池中能够容纳同时执行的最大线程数。
  1. keepAliveTime :多余的空闲线程的存活时间。
  1. unit :keepAliveTime 的单位。
  1. workQueue :任务队列,被提交但尚未被执行的任务。
  1. threadFactory :生成线程池中工作线程的线程工厂。
  1. handler :拒绝策略,表示当队列满了,并且工作线程大于等于线程池的最大线程数时如何拒绝请求执行的 runnable 的策略。

(四)线程池的工作原理图

线程池的工作原理如下:
  1. 在创建了线程池后,开始等待请求。
  1. 当调用 execute() 方法添加一个请求任务时,线程池会做出如下判断:
      • 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务。
      • 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入队列。
      • 如果这个时候队列满了且正在运行的线程数量还小于 maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务。
      • 如果队列满了且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会启动饱和拒绝策略来执行。
  1. 当一个线程完成任务时,它会从队列中取下一个任务来执行。
  1. 当一个线程无事可做超过一定的时间(keepAliveTime)时,线程会判断:如果当前运行的线程数大于 corePoolSize,那么这个线程就被停掉。

(五)线程池的拒绝策略

JDK 内置了四种拒绝策略:
  1. AbortPolicy(默认) :直接抛出 RejectedExecutionException 异常阻止系统正常运行。
  1. CallerRunsPolicy :“调用者运行” 一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者,从而降低新任务的流量。
  1. DiscardOldestPolicy :抛弃队列中等待最久的任务,然后把当前任务加入队列中尝试再次提交当前任务。
  1. DiscardPolicy :该策略默默地丢弃无法处理的任务,不予任何处理也不抛出异常。如果允许任务丢失,这是最好的一种策略。

(六)自定义线程池

可以使用 ThreadPoolExecutor 类来自定义线程池,以下是一个示例:

十三、总结

Java 多线程编程是 Java 开发中的核心技术之一,它涉及到线程基础、锁机制、线程间通信、并发工具类等多个方面的知识。通过深入理解多线程的核心原理,熟练掌握 JUC 包中的各类工具类和工具方法,结合线程池等技术,我们可以高效地解决各种复杂的并发问题,提升系统的性能和可靠性。在实际开发中,要根据具体的应用场景合理地选择和使用多线程技术,避免过度设计或滥用,从而实现高效、稳定的并发程序。
 
Keycloak 客户端授权服务Spring Boot加载与Bean处理的细节剖析
Loading...