多线程
多线程
1. 什么是进程?什么是线程?
- 进程是: 一个应用程序.
- 线程是:一个进程中的执行场景/执行单元
- 一个进程至少有一个线程
拿Java的HelloWorld来说:
对于Java程序来说,当在DOS命令窗口中运行以下代码:
1
2
3
4
5public class Test {
public static void main(String [] args){
System.out.print("Hello World")
}
}回车之后。会先启动JVM,而JVM就是一个进程。
JVM再启动一个主线程调用main方法(main方法就是主线程)。
同时再启动一个垃圾回收线程负责看护,回收垃圾。最起码,现在的java程序中至少有两个线程并发,一个是 垃圾回收线程,一个是 执行main方法的主线程。
2. 使用多线程有几种方法?
在Java中,创建多线程有几种主要的方式:
2.1 继承Thread类
这是最基本的创建线程的方式之一。你可以创建一个类,继承自Thread类,并重写其run()方法来定义线程的任务。然后创建该类的实例并调用start()方法来启动线程。
示例代码:
1 | |
2.2 实现Runnable接口
通过实现Runnable接口,可以将线程的任务逻辑与线程本身解耦,提高代码的灵活性。同样地,创建一个类,实现Runnable接口,并实现其run()方法来定义线程的任务。然后将该实现类的实例传递给Thread类的构造函数,并调用start()方法来启动线程。
示例代码:
1 | |
2.3 实现Callable接口
与Runnable接口类似,Callable接口也用于定义线程的任务,但它允许任务返回结果并抛出受检查的异常。可以使用ExecutorService的submit()方法来执行Callable任务。
示例代码:
1 | |
2.4 使用线程池创建
2.4.1 线程池的参数
ThreadPoolExecutor类允许我们指定核心线程数、最大线程数、线程空闲时间等参数来创建线程池。
corePoolSize: 核心线程数量,决定是否创建新的线程来处理到来的任务maximumPoolSize: 最大线程数量,线程池中允许创建线程地最大数量keepAliveTime: 线程空闲时存活的时间unit: 空闲存活时间单位workQueue: 任务队列,用于存放已提交的任务threadFactory: 线程工厂,用于创建线程执行任务handler: 拒绝策略,当线程池处于饱和时,使用某种策略来拒绝任务提交
语法结构:
1 | |
2.4.2 线程池的五种状态
- RUNNING:线程池处于 RUNNING 状态时,接受新任务,并处理队列中的任务。此时,线程池中的工作线程可以执行任务。处于 RUNNING 状态的线程池可以通过
shutdown()或shutdownNow()方法转换为其他状态。 - SHUTDOWN:线程池处于 SHUTDOWN 状态时,不再接受新任务,但会处理队列中的任务。此时,调用
shutdown()方法会使线程池转换到 SHUTDOWN 状态。 - STOP:线程池处于 STOP 状态时,不再接受新任务,不再处理队列中的任务,并且会尝试中断正在执行的任务。调用
shutdownNow()方法会使线程池转换到 STOP 状态。 - TIDYING:线程池处于 TIDYING 状态时,所有的任务已经终止,同时工作线程数已经达到了核心线程数。当所有任务都已终止,工作线程数为 0 时,线程池会转换到 TIDYING 状态。TIDYING 状态是用来执行一些清理操作的,例如调用
terminated()方法。 - TERMINATED:线程池处于 TERMINATED 状态时,线程池完全终止,不再执行任何任务。当线程池已经终止,且完成了清理操作后,会转换到 TERMINATED 状态。
这些状态在 ThreadPoolExecutor 类中由 volatile 修饰的 int 常量表示,用于控制线程池的行为和状态转换。通过调用 ThreadPoolExecutor 的不同方法,可以将线程池从一个状态转换到另一个状态,从而实现线程池的管理和控制。
####2.4.3 线程池的执行流程
如果
workerCount<corePoolSize==> 创建线程执行提交的任务如果
workerCount>=corePoolSize&& 阻塞队列未满 ==> 添加至阻塞队列,等待后续线程来执行提交地任务如果
workerCount>=corePoolSize&&workerCount<maxinumPoolSize&& 阻塞队列已满 ==> 创建非核心线程执行提交的任务如果
workerCount>=maxinumPoolSize&& 阻塞队列已满 ==> 执行拒绝策略
wokerCount: 需要处理的任务总数
corePoolSize: 核心线程
maxinumPoolSize: 最大线程数
2.4.3 线程池使用示例
以下是一个示例代码:
1 | |
2.4.x 如何选择线程池?
阿里巴巴《Java开发手册》给我们的答案:
【强制】线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
说明:Executors 返回的线程池对象的弊端如下:
1) FixedThreadPool 和 SingleThreadPool:允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。
2)CachedThreadPool:允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM(内存泄露)。
3. Java中的线程调用
3.1 常见的线程调用模型
- 抢占式调度模型:
那个线程的优先级比较高,抢到的CPU时间片的概率就高一些/多一些。
Java采用的就是抢占式调度模型。 - 均分式调度模型:
平均分配CPU时间片。每个线程占有的CPU时间片时间长度一样。
平均分配,一切平等。
有一些编程语言,线程调度模型采用的是这种方式。
3.2 Java中线程调用的方法
| 方法名 | 作用 |
|---|---|
| int getPriority() | 获得线程优先级 |
| void setPriority(int newPriority) | 设置线程优先级 |
- 最低优先级1
- 默认优先级是5
- 最高优先级10
优先级比较高的获取CPU时间片可能会多一些。(只是权重会高,实际情况多少会有些出入)
Thread类中还提供了一些静态常量:
| 常量名 | 作用 |
|---|---|
MAX_PRIORITY |
最高优先级,其值为10。线程被赋予最高优先级时,会优先于其他线程执行,但并不保证一定会先于其他线程执行完毕。 |
MIN_PRIORITY |
最低优先级,其值为1。线程被赋予最低优先级时,会处于最低优先级队列,通常情况下会被较少地调度执行。 |
NORM_PRIORITY |
默认优先级,其值为5。如果没有显式地为线程指定优先级,那么线程会被赋予默认的中等优先级。 |
两个线程之间的调度:
| 方法名 | 作用 |
|---|---|
| yield() | 使当前线程从执行状态(运行状态)变为可运行状态(就绪状态)。 |
| join() | 允许一个线程等待另一个线程执行完 |
yield()方法:yield()方法是Thread类的一个静态方法,它使当前线程从执行状态(运行状态)变为可运行状态(就绪状态)。换句话说,它让当前线程暂停执行,让同等优先级的其他线程有机会执行。调用yield()方法并不会释放锁或者让线程进入阻塞状态,仅是一个对线程调度器的建议,因此不能保证线程一定会被调度。一般来说,使用yield()方法是为了让其他线程有机会执行,以提高系统的响应性。join()方法:join()方法是Thread类的一个实例方法,它允许一个线程等待另一个线程执行完成。调用join()方法的线程将会阻塞,直到被调用的线程执行完成或者指定的等待时间到期。join()方法通常用于在主线程中等待所有子线程执行完成,然后再继续执行后续逻辑。此外,还有join(long millis)和join(long millis, int nanos)方法,允许设置等待时间。
4. 线程安全问题
4.1.什么时候数据在多线程并发的环境下会存在安全问题呢?
满足三个条件:
- 条件1:多线程并发。
- 条件2:有共享数据。
- 条件3:共享数据有修改的行为。
4.2 如何解决线程安全问题?
4.2.1 使用synchronized关键字
使用同步方法或同步代码块: 可以使用synchronized关键字来实现同步方法或同步代码块,确保多个线程不会同时访问共享资源,从而避免数据竞争和不一致性。
synchronized 语法
1 | |
4.2.1.1 synchronized 的使用场景
同步代码块: 使用
synchronized关键字将代码块包裹起来,确保同一时间只有一个线程可以执行该代码块。需要提供一个对象作为锁,通常使用某个对象实例或者类的静态成员作为锁对象。示例代码:
1
2
3synchronized (lockObject) {
// 同步的代码块
}同步方法: 在方法的声明上使用
synchronized关键字,使整个方法体成为一个同步代码块,确保同一时间只有一个线程可以执行该方法。同步方法的锁对象是当前对象的实例(对于静态方法则是当前类的Class对象)。示例代码:
1
2
3public synchronized void synchronizedMethod() {
// 同步的方法体
}同步类: 在类的声明上使用
synchronized关键字,使整个类成为一个同步代码块,确保同一时间只有一个线程可以访问该类的任何同步方法或同步代码块。同步类的锁对象是该类的Class对象。示例代码:
1
2
3
4
5
6
7
8
9
10
11
12public class SynchronizedClass {
public void method1() {
synchronized (SynchronizedClass.class) {
// 同步的代码块
}
}
public synchronized void method2() {
// 同步的方法体
}
}
4.2.1.2 synchronized实现原理:
在Java中,每个对象都有一个对象头(Object Header),对象头中包含了一些与对象自身相关的信息,其中包括对象的哈希码、锁状态标志等。在使用synchronized关键字时,JVM会使用对象头中的锁状态来实现同步。
具体来说,当一个线程执行到synchronized代码块或方法时,它会尝试获取对象的锁。如果这个对象的锁状态为无锁状态(即没有任何线程持有锁),那么该线程就可以获取到锁,并将锁状态设置为锁定状态。如果这个对象的锁状态为锁定状态,并且是由其他线程持有的,那么当前线程就会被阻塞,直到持有锁的线程释放锁。
在Java中,锁状态通常被表示为一个标志位(1表示锁定状态,0表示无锁状态),因此synchronized关键字的底层原理其实就是通过对象头中的锁状态来实现线程同步的。
####4.2.2 使用volatile关键字
在多线程环境中,volatile关键字可以确保可见性,即一个线程对volatile变量的修改会立即被其他线程所感知,从而避免出现脏读或者数据不一致的问题。
假设我们有一个简单的场景:一个变量被两个线程访问和修改。其中,一个线程不断读取这个变量的值,另一个线程不断更新这个值。如果这个变量被声明为 volatile,那么任何一个线程对这个变量的修改都会立即反映给另外一个线程;反之,如果不使用 volatile,则一个线程修改的值可能对另一个线程不可见。
1 | |
在这个例子中,flag 被声明为 volatile 类型。这意味着,当线程2修改了 flag 的值时,线程1几乎立即就能感知到 flag 值的变化,并跳出循环,输出提示信息。如果我们去掉 volatile 关键字,就不能保证线程1能够及时感知到 flag 的变化,甚至可能会导致线程1永远处于循环中,因为线程1可能会把 flag 的初值缓存在自己的工作内存中,而忽略主内存中 flag 的更新。
4.2.2.1 volatile 无法保证原子性
虽然volatile 保证了变量修改的可见性但是它不涉及锁定,因此不保证操作的原子性
比如 i++这样的操作
像 i++ 这样的自增操作之所以不能仅通过 volatile 来保证线程安全,是因为 i++ 实际上是一个复合操作,包含了三个独立的步骤:读取变量的当前值、增加变量的值、写回新值到变量。这三个步骤不是原子性的,当多个线程同时执行这样的操作时,它们之间可能会互相干扰,导致数据不一致的问题。
来具体分析一下过程:
- **读取 (
read)**:首先,线程读取i当前的值到自己的工作内存中。 - **增加 (
increment)**:然后,线程在自己的工作内存中将i的值加1。 - **写回 (
write)**:最后,线程将更新后的值写回到主内存中。
在多线程环境中,如果两个或多个线程几乎同时执行这个操作,它们可能都会在自己的工作内存中读取到 i 的相同值,然后各自增加1,并写回新值到主内存中。这样就会导致 i 实际上被增加了多次,但结果只增加了1次,因为多个线程读取和写回的操作互相覆盖了。
即使 i 被声明为 volatile,虽然每次写操作都会立即同步到主内存中,确保了可见性,但是由于 i++ 的复合操作性质,它不能保证在读-改-写这整个过程中的原子性。因此,在并发环境下,仅仅依靠 volatile 关键字是不足以使 i++ 操作成为线程安全的。
要安全地实现 i++ 操作,可以使用如下方法:
- 使用
synchronized关键字或锁(如ReentrantLock)来确保每次只有一个线程可以执行这个操作。 - 使用
AtomicInteger这类的原子类,Java 并发包中提供了这种线程安全的数字操作类,它们可以保证此类操作的原子性。
4.2.2.2 volatile 的底层原理
- 内存可见性
- 主内存与工作内存:在Java内存模型中,所有变量都存储在主内存中,而每个线程都有自己的工作内存,线程的工作内存中保存了该线程使用到的变量的主内存副本。线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,不能直接读写主内存中的数据。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递需要通过主内存来完成。
- 可见性保证:当变量被声明为
volatile后,线程对这个变量的修改会立即强制刷新到主内存中,同时,每次使用这个变量之前,都会从主内存中重新读取它的值,而不是使用工作内存中的缓存。这就保证了一个线程修改了某个volatile变量的值后,其他线程能够立即看到这一变化。
- 禁止指令重排序
- 指令重排序:在不影响单线程程序执行结果的前提下,编译器和处理器为了优化程序性能可能会对指令序列进行重排序。但是,在多线程环境中,这种重排序可能会破坏线程安全。
- 有序性保证:
volatile变量的另一个关键作用是禁止对其读写操作的指令重排序,确保在它之前的操作都不会被重排到它之后,它之后的操作也不会被重排到它之前。这样,即使在多线程环境中,也能保证基于volatile变量的操作顺序符合预期。
- 底层支持
实现 volatile 的底层支持主要依赖于处理器的内存屏障指令:
- 内存屏障(Memory Barrier):是一种处理器指令,用于实现对内存操作的顺序限制。内存屏障有多种类型,每种类型都有其特定的作用,如确保某些特定操作的执行顺序,防止指令之间的重排序等。
- 当写入一个
volatile变量时,会在写操作后插入一条存储屏障(Store Barrier),确保对这个volatile变量的写操作对其他线程立即可见(通过将当前处理器缓存行的数据写回到系统内存)。 - 当读取一个
volatile变量时,会在读操作前插入一条加载屏障(Load Barrier),确保对这个volatile变量的读操作能够看到最新的值(通过无效化当前处理器缓存行,强制从系统内存中读取)。
4.2.3 使用Lock接口
Java提供了Lock接口及其实现类(如ReentrantLock),通过Lock接口可以实现更灵活和精确的锁定机制,避免了使用synchronized关键字可能出现的一些问题,例如死锁。
4.2.3.1 Lock的核心方法
| 方法名 | 作用 |
|---|---|
void lock() |
获取锁。如果锁不可用,则当前线程将处于休眠状态直到锁被释放。 |
void lockInterruptibly() throws InterruptedException |
如果当前线程未被中断,则获取锁。如果已经被中断,则抛出一个 InterruptedException |
boolean tryLock() |
尝试获取锁,如果锁可用立即返回 true,否则返回 false。该方法会立即返回,不会等待。 |
boolean tryLock(long time, TimeUnit unit) throws InterruptedException |
如果锁在给定等待时间内变得可用,并且当前线程未被中断,则获取锁。 |
void unlock() |
释放锁 |
Condition newCondition() |
返回绑定到此 Lock 实例的新 Condition 实例。 |
4.2.3.2 Lock示例
下面是一个使用 ReentrantLock 类(Lock 接口的一个实现)的示例,展示了如何在代码中使用它来实现同步:
1 | |
与 synchronized 相比
- 灵活性:
Lock提供了更灵活的锁定操作,允许尝试非阻塞地获取锁、尝试可中断地获取锁以及在指定的等待时间内获取锁。 - 公平性:某些
Lock实现(如ReentrantLock)允许你构建公平锁,这种锁会按照线程等待的顺序来获取锁。 - 锁绑定多个条件:
Lock允许一个锁关联一个或多个条件对象(Condition类),这可以分开管理不同的等待线程集合,提供了类似Object监视器方法(wait、notify和notifyAll)的功能,但更灵活。
使用 Lock 还是 synchronized 取决于具体的应用场景和对灵活性、响应中断、定时锁等待、公平性锁定等的需求。
4.2.3.3 Lock的底层原理
Lock 接口的实现,尤其是 ReentrantLock,是基于 Java 并发包 java.util.concurrent 中的高级同步机制实现的。它的底层原理较为复杂,涉及到多种并发编程的基本概念,包括但不限于:CAS(Compare-And-Swap)操作、AQS(AbstractQueuedSynchronizer)框架、以及Java内存模型(JMM)。下面是对这些概念和 ReentrantLock 底层工作原理的简要说明:
- CAS(Compare-And-Swap)
- 基础:CAS 是一种原子操作,用于实现变量的无锁编程。它包含三个操作数——内存位置(V)、预期原值(A)和新值(B)。只有当内存位置的值与预期原值相匹配时,系统才将该位置值更新为新值。这个操作是原子的,即中间不会被线程调度机制打断。
- 用途:在
ReentrantLock的实现中,CAS 用来实现锁状态的管理,比如尝试获取锁时更新状态值。
- AQS(
AbstractQueuedSynchronizer)
- 核心:AQS 是构建锁或其他同步器的框架,提供了一个基于FIFO队列的框架,用于管理等待获取锁的线程。它使用一个int成员变量表示同步状态,通过内置的FIFO队列来管理那些失败获取同步状态的线程。
- 同步状态:AQS 使用一个整型的变量来表示同步状态,并通过protected方法来改变这个状态。锁实现者可以根据自己的需要来决定状态的意义。
- 节点和队列:AQS 内部通过一个名为
Node的静态嵌套类来表示等待队列中的每一个元素,每个节点包含了一个线程引用。当线程尝试获取锁而失败时,AQS 会将该线程包装成一个节点加入到等待队列的尾部;当锁被释放时,头节点的线程会尝试再次获取锁。
- 锁的获取与释放
- 获取锁:当线程尝试获取锁时,
ReentrantLock首先尝试通过CAS设置同步状态,如果成功(意味着没有其他线程持有锁),则拥有了锁。如果失败,AQS会将当前线程封装成节点放入等待队列,同时线程将会被阻塞。 - 释放锁:当锁持有者线程释放锁时,
ReentrantLock会更新同步状态并检查等待队列是否有因等待锁而阻塞的线程,如果有,将会选择一个(通常是队列头部的)线程来尝试获取锁。
- 公平性和非公平性
ReentrantLock提供了公平锁和非公平锁两种模式。公平锁意味着锁的分配会遵循队列中等待时间最长的线程优先的原则;非公平锁则允许插队,可能会导致等待时间较长的线程出现饥饿现象。公平锁在锁释放时总是选择等待时间最长的线程来获取锁,而非公平锁则允许其他线程抢占,从而可能直接获取锁。
总结
ReentrantLock 的实现复杂且高效,它通过结合CAS操作、AQS框架以及Java内存模型(JMM)提供的内存可见性保证,实现了一种比传统 synchronized 方法更灵活、更高性能的线程同步机制。
4.2.4 使用并发容器
Java提供了一些并发安全的容器类,例如ConcurrentHashMap、CopyOnWriteArrayList等,这些容器类在多线程环境下可以安全地访问和修改共享数据。
ConcurrentHashMap 和 CopyOnWriteArrayList 都是 Java 中的并发集合,设计用于在多线程环境中提供高性能的线程安全性。它们通过具体的策略来最小化锁的竞争,从而提高并发访问的效率。下面分别介绍这两个类的特点和工作原理。
4.2.4.1 ConcurrentHashMap
ConcurrentHashMap 是一个线程安全的哈希表,用于在多线程环境中替代同步的 HashMap (Collections.synchronizedMap(new HashMap<...>()))。它通过分段锁(Segmentation)的概念提供了比 Hashtable 更好的并发性能。
分段锁:
ConcurrentHashMap将数据分成一段一段存储,然后给每一段数据配上一把锁。当一个线程占用锁访问其中一段数据时,其他段的数据也能被其他线程访问。ConcurrentHashMap默认将数据分为 16 段,每当进行修改操作时,只需锁定相应的段,而不是锁定整个哈希表。读取不加锁:对于读取操作,
ConcurrentHashMap不加锁,因为它在内部采用了一种不可变的数据结构,即使没有加锁,也可以保证读取操作的安全性。迭代器弱一致性:
ConcurrentHashMap的迭代器(和集合视图)提供了弱一致性,而不是快速失败(fail-fast)。这意味着迭代器在创建后不会抛出ConcurrentModificationException,它们可以容忍并发的修改。
4.2.4.2 CopyOnWriteArrayList
CopyOnWriteArrayList 是一个线程安全的 List 实现,适用于读多写少的并发场景。如其名,该类的核心思想是“写入时复制”(Copy-On-Write)。
写入时复制:每当我们尝试修改(添加、删除、设置等)
CopyOnWriteArrayList中的内容时,它实际上会先复制一份数据,然后在这份新的副本上进行修改。修改完成后,再将原来的引用指向新的副本。这样读取操作仍然可以安全地执行,而不受并发修改的影响。迭代器不支持修改:
CopyOnWriteArrayList的迭代器不支持remove、add和set操作,抛出UnsupportedOperationException。读取性能高:由于读取操作不需要加锁,所以在读多写少的场景下,
CopyOnWriteArrayList的读取性能非常高。内存开销:由于每次修改都要复制整个底层数组,
CopyOnWriteArrayList在内存使用上比较昂贵,特别是当列表很大时。因此,它通常适用于包含少量修改操作的场景。
总结
ConcurrentHashMap 和 CopyOnWriteArrayList 都是解决并发环境下集合操作的有效工具,它们通过不同的策略(分段锁和写入时复制)来提高并发性能和线程安全。选择哪一个取决于具体的应用场景:ConcurrentHashMap 适用于高并发的键值对操作,而 CopyOnWriteArrayList 更适用于读操作远远多于写操作的列表处理场景。
4.2.5 使用原子类
Java提供了一系列的原子类,例如AtomicInteger、AtomicLong等,它们提供了一些原子操作,可以在不使用锁的情况下实现线程安全的操作。
4.2.6 避免死锁
在设计多线程程序时要注意避免死锁的发生,即避免多个线程之间相互等待对方持有的资源而无法继续执行的情况。
4.2.7 使用线程安全的第三方库
在开发过程中,可以使用一些已经经过验证的线程安全的第三方库,如Apache Commons中的并发工具类。