type
status
date
slug
summary
tags
category
icon
password
catalog
sort
Java 多线程深度剖析与实战指南
一、前言
在 Java 的世界里,多线程编程一直是开发人员眼中既神秘又充满挑战的领域。从简单的线程创建与启动,到复杂的线程安全问题和性能优化,多线程编程贯穿了 Java 应用开发的始终。本文将深入浅出地剖析 Java 多线程的核心概念、原理和实战技巧。
二、多线程基础
(一)线程与进程
- 进程 进程是具有一定独立功能的程序关于某个数据集合的一次运行活动,是操作系统动态执行的基本单元。在操作系统中,进程既是基本的分配单元,也是基本的执行单元。例如,当你同时运行游戏和网易云音乐时,这两个程序分别对应着两个不同的进程。
- 线程 线程是进程中的一个实体,是 CPU 调度和分派的基本单位。一个进程可以包含多个线程,这些线程共享进程的资源,如内存地址空间、文件句柄等。线程是独立调度和执行的最小单位。比如在 IDEA 中编写代码时,语法检查和自动保存功能就是多线程操作的体现。
(二)线程的生命周期
Java 中的线程有 6 种状态,分别是新建(NEW)、运行(RUNNABLE)、阻塞(BLOCKED)、等待(WAITING)、计时等待(TIMED_WAITING)和终止(TERMINATED)。其中,新建状态表示线程已被创建但尚未启动;运行状态表示线程正在 JVM 中执行;阻塞状态指线程被阻塞,无法获取锁;等待状态是线程在等待其他线程的通知;计时等待状态类似于等待状态,但有一个等待时间限制;终止状态表示线程已执行完毕。
(三)并发与并行
- 并发 并发指的是多个线程共享同一资源,交替执行。例如在秒杀抢购活动中,多个用户同时对一个商品发起抢购请求,这些请求对应的线程就是并发执行的。
- 并行 并行则指多项工作同时执行,之后再汇总结果。比如泡方便面时,电水壶烧水和撕调料倒入桶中这两个操作就可以并行进行。
(四)wait 与 sleep 的区别
- wait
wait()
是Object
类的方法,调用该方法的线程会释放锁,进入等待状态,直到其他线程调用notify()
或notifyAll()
方法通知它。
- sleep
sleep()
是Thread
类的静态方法,调用该方法的线程在指定时间内暂停执行,但不会释放锁。
三、JUC(Java Util Concurrency)概述
JUC 是 JDK 5 引入的高并发工具类的集合,位于
java.util.concurrent
包下,主要包括锁、并发集合、并发工具类等,为解决多线程编程中的各种问题提供了强大的支持。四、锁机制
(一)synchronized
synchronized
是 Java 内置的关键字,用于实现同步操作。它可以修饰方法、代码块等,其核心原理是利用 Java 中的每一个对象都可以作为锁。例如,对于普通同步方法,锁是当前实例对象;对于静态同步方法,锁是当前类的 Class 对象;对于同步代码块,锁是括号里面配置的对象。以下是 synchronized
的一个案例:(二)Lock 接口
Lock
接口是 JDK 5 引入的新的多线程锁,提供了比 synchronized
更加灵活的锁机制。ReentrantLock
是 Lock
接口的实现类,它支持可重入、可公平等多种锁机制。以下是 ReentrantLock
的一个案例:与
synchronized
相比,Lock
有以下区别:synchronized
是 Java 内置关键字,在 JVM 层面,Lock
是个 Java 类。
synchronized
无法判断是否获取锁的状态,Lock
可以判断是否获取到锁。
synchronized
会自动释放锁,Lock
需在 finally 中手工释放锁,否则容易造成线程死锁。
synchronized
的锁不可中断,Lock
的锁可中断。
synchronized
的锁是非公平的,Lock
的锁可公平可非公平。
五、创建线程的方式
在 Java 中,创建线程主要有以下几种方式:
- 继承 Thread 类
- 实现 Runnable 接口
- 使用 Lambda 表达式
- 使用 FutureTask 类
- 使用自定义线程池
六、线程间通信
(一)线程通信的方式
线程间通信主要通过生产者 - 消费者模式和通知等待唤醒机制来实现。生产者 - 消费者模式是一种典型的多线程协作模式,生产者负责生产数据,消费者负责消费数据。通知等待唤醒机制则是通过
wait()
、notify()
和 notifyAll()
等方法来实现线程之间的通信。(二)多线程编程模板
多线程编程模板主要包括判断、干活和通知三个步骤。以下是一个基于
synchronized
实现的线程间通信的案例:在 JDK 8 中,还可以使用
Condition
来实现线程间通信,它提供了比 wait()
和 notify()
更加灵活的通信方式:七、多线程锁
在多线程编程中,锁是实现线程安全的关键。以下是关于多线程锁的一些要点:
- 一个对象中的多个
synchronized
方法,某一时刻内只能有一个线程访问这些方法,因为锁的是当前对象this
。
- 静态同步方法的锁是当前类的 Class 对象。
- 普通同步方法与静态同步方法之间互不影响,因为它们使用的锁不同。
八、JUC 之集合
(一)集合中的不安全类
- List
ArrayList
在迭代时如果同时对其进行修改,会抛出ConcurrentModificationException
异常。解决方法包括使用Vector
、Collections.synchronizedList
或CopyOnWriteArrayList
。
- Set
HashSet
是线程不安全的,可以使用CopyOnWriteArraySet
来解决线程安全问题。
- Map
HashMap
也是线程不安全的,ConcurrentHashMap
则提供了线程安全的解决方案。
(二)CopyOnWriteArrayList 原理
CopyOnWriteArrayList
是基于写时复制(CopyOnWrite)机制的线程安全集合。它的核心原理是,在对集合进行写操作(如添加、删除等)时,不会直接修改原集合,而是先创建一个原集合的副本,在副本上进行修改操作,修改完成后,再将原集合的引用指向新的副本。这样,在整个写操作过程中,读操作可以不受影响地在原集合上进行,从而实现了读写分离,保证了线程安全。以下是 CopyOnWriteArrayList
的部分源码:九、Callable 接口与 FutureTask 类
(一)Callable 接口
- 简介
Callable
接口是 Java 5 引入的,它与Runnable
接口类似,但比Runnable
更强大。Callable
接口允许线程执行的任务返回一个结果,并且可以抛出异常。它只有一个方法call()
,该方法定义了线程执行的任务,可以返回一个结果,并且可以抛出受检异常。
- 与 Runnable 接口的区别
- 是否有返回值:
Callable
的call()
方法可以返回结果,而Runnable
的run()
方法没有返回值。 - 是否抛异常:
Callable
的call()
方法可以抛出受检异常,而Runnable
的run()
方法不能抛出受检异常。 - 落地方法不一样:
Callable
的落地方法是call()
,而Runnable
的落地方法是run()
。
(二)FutureTask 类
- 简介
FutureTask
是 Java 5 引入的类,它实现了RunnableFuture
接口,代表一个异步计算的任务。它提供了对异步任务的执行和结果获取的支持。你可以将FutureTask
提交给线程池执行,也可以直接启动一个线程来执行它。
- 原理
在主线程中需要执行比较耗时的操作时,但又不想阻塞主线程时,可以把这些作业交给
FutureTask
在后台完成。当主线程将来需要时,就可以通过FutureTask
的get()
方法获取后台作业的计算结果或者执行状态。get()
方法会阻塞,直到任务完成。以下是FutureTask
的一个案例:
十、JUC 强大的辅助类
(一)CountDownLatch(减少计数)
- 原理
CountDownLatch
是一个同步辅助类,它允许一个或多个线程等待其他线程完成操作。它主要有两个方法,await()
和countDown()
。当一个或多个线程调用await()
方法时,这些线程会阻塞。其他线程调用countDown()
方法会将计数器减 1,当计数器的值变为 0 时,因await()
方法阻塞的线程会被唤醒,继续执行。
- 案例实现
(二)CyclicBarrier(循环栅栏)
- 原理
CyclicBarrier
是一个同步辅助类,它允许一组线程互相等待,直到到达一个共同的屏障点。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。线程进入屏障通过CyclicBarrier
的await()
方法。
- 案例实现
(三)Semaphore(信号灯)
- 原理
Semaphore
是一个计数信号量,它可以控制同时访问特定资源的线程数量。在信号量上定义两种操作:acquire
(获取)和release
(释放)。当一个线程调用acquire
操作时,它要么通过成功获取信号量(信号量减 1),要么一直等下去,直到有线程释放信号量,或超时。release
操作实际上会将信号量的值加 1,然后唤醒等待的线程。信号量主要用于两个目的,一个是用于多个共享资源的互斥使用,另一个用于并发线程数的控制。
- 案例实现
(四)ReentrantReadWriteLock(读写锁)
- 实际案例 在缓存框架中,读写锁可以很好地控制缓存的读写操作。例如,当多个线程同时读取缓存时,可以允许多个线程同时进行读操作;而当有线程需要写入缓存时,只允许一个线程进行写操作,并且此时不允许其他线程进行读写操作。
- 案例实现
十一、BlockingQueue(阻塞队列)
(一)阻塞队列简介
阻塞队列是一种特殊的队列,当队列为空时,获取元素的线程会自动阻塞,直到有元素被添加到队列中;当队列已满时,添加元素的线列会自动阻塞,直到有元素被移出队列。Java 中的阻塞队列位于
java.util.concurrent.BlockingQueue
接口下,常见的实现类有 ArrayBlockingQueue
、LinkedBlockingQueue
、PriorityBlockingQueue
、DelayQueue
、SynchronousQueue
、LinkedTransferQueue
和 LinkedBlockingDeque
等。(二)核心方法介绍
阻塞队列的核心方法主要包括以下几类:
- 插入元素
offer(E e)
:将元素插入队列,如果队列已满,返回false
。offer(E e, long timeout, TimeUnit unit)
:将元素插入队列,在指定时间内等待插入成功,如果超时则返回false
。put(E e)
:将元素插入队列,如果队列已满,阻塞等待。
- 移除元素
poll()
:移除队列中的头元素,如果队列为空,返回null
。poll(long timeout, TimeUnit unit)
:移除队列中的头元素,在指定时间内等待移除成功,如果超时则返回null
。take()
:移除队列中的头元素,如果队列为空,阻塞等待。
- 获取元素
peek()
:获取队列中的头元素,但不移除它,如果队列为空,返回null
。
十二、线程池
(一)线程池的优点
- 降低资源消耗 通过重复利用已创建的线程,降低线程创建和销毁造成的资源消耗。
- 提高响应速度 当任务到达时,任务可以不需要等待线程创建就能立即执行。
- 提高线程的可管理性 线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性。使用线程池可以进行统一的分配、调优和监控。
(二)线程池架构
Java 中的线程池是通过 Executor 框架实现的,该框架中用到了 Executor、Executors、ExecutorService、ThreadPoolExecutor 这几个类。以下是 JDK 默认的几种线程池创建方法:
- newFixedThreadPool(int nThreads) 创建一个固定大小的线程池,线程池中的线程数量固定,所有任务都被放入一个无界任务队列中。当线程池中的线程都在执行任务时,新的任务会在队列中等待。
- newSingleThreadExecutor() 创建一个单线程的线程池,该线程池只有一个线程,所有任务都被依次执行。
- newCachedThreadPool() 创建一个大小不固定的线程池,线程池的大小可以动态增加。当线程空闲时间超过 60 秒时,线程会被自动回收。
(三)ThreadPoolExecutor 底层原理
ThreadPoolExecutor
是 Java 中线程池的核心实现类,它通过一组参数来控制线程池的行为。以下是 ThreadPoolExecutor
的构造方法:其主要参数包括:
- corePoolSize :线程池中的常驻核心线程数。
- maximumPoolSize :线程池中能够容纳同时执行的最大线程数。
- keepAliveTime :多余的空闲线程的存活时间。
- unit :keepAliveTime 的单位。
- workQueue :任务队列,被提交但尚未被执行的任务。
- threadFactory :生成线程池中工作线程的线程工厂。
- handler :拒绝策略,表示当队列满了,并且工作线程大于等于线程池的最大线程数时如何拒绝请求执行的 runnable 的策略。
(四)线程池的工作原理图
线程池的工作原理如下:
- 在创建了线程池后,开始等待请求。
- 当调用
execute()
方法添加一个请求任务时,线程池会做出如下判断: - 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务。
- 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入队列。
- 如果这个时候队列满了且正在运行的线程数量还小于 maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务。
- 如果队列满了且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会启动饱和拒绝策略来执行。
- 当一个线程完成任务时,它会从队列中取下一个任务来执行。
- 当一个线程无事可做超过一定的时间(keepAliveTime)时,线程会判断:如果当前运行的线程数大于 corePoolSize,那么这个线程就被停掉。
(五)线程池的拒绝策略
JDK 内置了四种拒绝策略:
- AbortPolicy(默认) :直接抛出
RejectedExecutionException
异常阻止系统正常运行。
- CallerRunsPolicy :“调用者运行” 一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者,从而降低新任务的流量。
- DiscardOldestPolicy :抛弃队列中等待最久的任务,然后把当前任务加入队列中尝试再次提交当前任务。
- DiscardPolicy :该策略默默地丢弃无法处理的任务,不予任何处理也不抛出异常。如果允许任务丢失,这是最好的一种策略。
(六)自定义线程池
可以使用
ThreadPoolExecutor
类来自定义线程池,以下是一个示例:十三、总结
Java 多线程编程是 Java 开发中的核心技术之一,它涉及到线程基础、锁机制、线程间通信、并发工具类等多个方面的知识。通过深入理解多线程的核心原理,熟练掌握 JUC 包中的各类工具类和工具方法,结合线程池等技术,我们可以高效地解决各种复杂的并发问题,提升系统的性能和可靠性。在实际开发中,要根据具体的应用场景合理地选择和使用多线程技术,避免过度设计或滥用,从而实现高效、稳定的并发程序。
- 作者:Honesty
- 链接:https://blog.hehouhui.cn/archives/21e0c7d0-9e17-8000-a8f8-fc6b4b605965
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。
相关文章