JAVA基础—多线程基础
1. 基本概念
-
什么是进程
进程(Process)是计算机中正在运行的程序实例。它包含了程序的代码和程序在执行过程中所需要的资源,如内存、文件句柄等。
每个进程都有独立的内存空间,使得它们可以独立运行,互不干扰。在多任务操作系统中,一个计算机可以同时运行多个进程,每个进程都拥有自己的一部分系统资源。 -
什么是线程
线程(Thread)是程序中执行的单个任务,是程序中的最小单元。一个进程可以包含多个线程,每个线程负责不同的任务,多个线程共享同一进程的内存空间和资源。通过使用多线程,可以让程序实现并发执行,提高程序的效率和性能。 -
进程与线程之间的区别
当谈到进程时,我们通常指的时正在运行的程序的实例,它拥有独立的内存空间和系统资源。而线程是进程中的一个执行单元,一个进程可以包含多个线程,它们共享相同的内存空间和资源。因此,线程的切换开销通常比进程小,线程更适合于需要频繁切换和共享数据的任务。
2. 线程的生命周期
在Java中,线程的生命周期包括以下几个状态
- New(新建):当线程对象对创建时,它处于新建状态,此时还没有开始运行。
- Runnable(可运行):当调用线程对象的‘start()’方法后,线程进入可运行状态,此时线程可能正在运行,也可能处于等待调度的状态
- Running(运行):线程被调度并执行‘run()’方法时,进入运行状态
- Blocked(阻塞):线程在等待某个条件(如获取锁、等待输入输出)时,会进入阻塞状态,此时不会占用CPU资源
- Waiting(等待):线程调用‘Object.wait()’‘Thread.join()’或‘LockSupport.park()’方法进入等待状态,等待某个条件满足后被唤醒
- Timed Waiting(计时等待):线程调用带有超时参数的Object.wait(timeout)、Thread.sleep(timeout)、Thread.join(timeout)、LockSupport.parkNanos(timeout) 或 LockSupport.parkUntil(timeout) 方法进入计时等待状态,等待一段时间后自动唤醒
- Terminated(终止):线程执行完‘run()’方法或者因异常退出,进入终止状态,线程生命周期结束
- join()
join() 方法是 Thread 类的一个方法,用于让一个线程等待另一个线程的结束。具体来说,调用线程(当前线程)会在调用 join() 方法的线程(目标线程)上等待,直到目标线程执行完毕或者等待超时。
join() 方法有多个重载形式,其中最常用的是无参的 join() 方法,它会让调用线程等待目标线程执行完毕:
在这个例子中,main 线程启动了一个新线程 thread,然后调用了 thread.join(),这会使 main 线程等待直到 thread 线程执行完毕后才会继续执行。Thread thread = new Thread(() -> { System.out.println("Child thread running"); }); thread.start(); thread.join(); // 等待 thread 执行完毕 System.out.println("Main thread running");
除了无参的 join() 方法外,还有一个带有超时参数的 join(long millis) 方法,它可以让调用线程等待一定的时间,如果超过指定时间目标线程还没有执行完毕,则会继续执行调用线程。
小结:
- Java线程对象Thread状态包括:New、Runnable、Blocked、Waiting、Timed Waiting和Terminate;
- 当前线程会在调用‘join()’方法的线程上等待其执行结束,再往下执行代码;
- 让线程等待时,可以指定等待时间
3.线程的创建与启动
在Java中,要创建并启动一个线程,通常有两种方式:
-
继承Thread类:创建一个类,继承自‘java.lang.Thread’类,并重写其‘run()’方法定义线程执行的任务。然后可以创建该类的实例,并调用‘start()’方法来启动线程。
class MyThread extends Thread { public void run() { // 线程执行的任务 System.out.println("Thread is running"); } } public class Main { public static void main(String[] args) { MyThread thread = new MyThread(); thread.start(); // 启动线程 } }
-
实现 Runnable 接口:创建一个类,实现 java.lang.Runnable 接口,并实现其 run() 方法来定义线程执行的任务。然后可以创建该类的实例,并将其传递给一个新的 Thread 实例来启动线程。
class MyRunnable implements Runnable { public void run() { // 线程执行的任务 System.out.println("Thread is running"); } } public class Main { public static void main(String[] args) { MyRunnable myRunnable = new MyRunnable(); Thread thread = new Thread(myRunnable); thread.start(); // 启动线程 } }
-
实现 Runnable 接口
Callable 是 Java 中一个接口,它类似于 Runnable 接口,用于表示可以由线程执行的任务。但与 Runnable 不同的是,Callable 的 call() 方法可以返回执行结果,并且可以抛出异常。
Callable 接口定义了如下方法:
public interface Callable<V> { V call() throws Exception; }
call() 方法类似于 Runnable 的 run() 方法,但是它可以返回一个结果,这个结果的类型由泛型参数 V 指定。call() 方法可以抛出异常,因此在调用 call() 方法时需要处理可能抛出的异常。
Callable 接口通常与线程池结合使用,线程池可以执行 Callable 任务,并返回一个 Future 对象,通过 Future 对象可以获取任务的执行结果或者取消任务的执行。
ExecutorService executor = Executors.newCachedThreadPool(); Callable<Integer> task = () -> { // 执行一些耗时的操作 return 123; }; Future<Integer> future = executor.submit(task); try { Integer result = future.get(); // 获取任务的执行结果 System.out.println("Result: " + result); } catch (InterruptedException | ExecutionException e) { // 处理异常 } finally { executor.shutdown(); }
4. 中断线程
在 Java 中,可以使用 Thread.interrupt() 方法来中断一个线程。当一个线程调用 interrupt() 方法时,它会设置中断标志位,表示该线程被中断了。被中断的线程可以通过检查中断标志位来判断是否被中断,并做出相应的处理。
以下是中断线程的一些情况:
-
在目标线程中使用‘Thread.interrupted()’或者‘isInterrupted()'方法检查中断标志位,并且根据需要做出相应的处理。通常在循环中检查中断标志位,一边在中断时能够及时推出循环。
public void run() { while (!Thread.interrupted()) { // 线程任务 } }
-
在其他线程中调用目标线程的interrupt() 方法来中断目标线程。
Thread targetThread = ...; // 目标线程 targetThread.interrupt(); // 中断目标线程
-
在目标线程中适时检查中断标志位,并根据情况处理中断。可以通过捕获‘InterruptedException’异常来响应中断。
try { while (!Thread.interrupted()) { // 线程任务 } } catch (InterruptedException e) { // 中断处理逻辑 }
需要注意的是,interrupt() 方法只是设置中断标志位,它并不会立即中断线程的执行。被中断的线程需要在适当的时候检查中断标志位,并根据需要做出响应。
除了使用 Thread.interrupt() 方法来中断线程外,还可以通过使用 Thread.stop() 方法来强制停止一个线程。但是,Thread.stop() 方法已被标记为过时,不推荐使用,因为它可能会导致线程在不安全的状态下终止,引发不可预料的问题。
5.守护线程
守护线程(Daemon Thread)是一种在后台提供服务的线程,它并不是程序中必不可少的部分,当所有的非守护线程结束时,守护线程会自动结束。守护线程通常用于提供后台服务或者执行一些支持性任务,例如垃圾回收线程就是一个典型的守护线程。
在 Java 中,可以通过调用 setDaemon(true) 方法将一个线程设置为守护线程。需要注意的是,将线程设置为守护线程必须在启动线程之前进行,否则会抛出 IllegalThreadStateException 异常。
Thread daemonThread = new Thread(() -> {
while (true) {
// 后台任务
}
});
daemonThread.setDaemon(true); // 设置为守护线程
daemonThread.start(); // 启动线程
需要注意的是,当所有的非守护线程结束时,JVM 会自动关闭所有守护线程,不会等待守护线程执行完毕。因此,在使用守护线程时,需要确保守护线程的任务是可以随时中断或者丢弃的。
守护线程的常见用途:
- 后台任务处理:守护线程适用于处理一些后台任务,如日志记录、监控、定时任务等,这些任务通常不需要阻塞主程序的执行,且可以随时中断或者丢弃。
- 垃圾回收:垃圾回收器是一个典型的守护线程,它负责回收不再被使用的内存,当所有的非守护线程结束时,垃圾回收线程也会随之结束。
- 实时性要求不高的任务:对于一些实时性要求不高的任务,可以将其作为守护线程运行,以便在程序结束时能够自动关闭
- 服务端口监听:守护线程可以用于监听服务端口,当所有的客户端连接关闭时,守护线程也会自动结束,释放资源。
4.线程的同步
线程同步是指一种机制,用于控制多个线程按照一定的顺序来访问共享资源,避免出现竞争条件(Race Condition).在Java中,可以使用‘synchronized’关键字或者‘java.util.concurrent.locks’包中的锁来实现同步
4.1 synchronized 关键字
synchronized 是 Java 中用于实现同步的关键字,它可以应用于方法或代码块,用于控制多个线程对共享资源的访问。当一个方法或代码块被 synchronized 修饰时,只有获取了对应的锁的线程才能执行该方法或代码块,其他线程需要等待锁释放后才能执行。
-
synchronized 修饰同步方法
使用 synchronized 修饰方法,可以使得整个方法在执行时都是同步的,即同一时刻只有一个线程可以执行该方法。这种方式适用于控制整个方法的访问。
java public synchronized void synchronizedMethod() { // 同步代码块 }
synchronized 锁的是synchronizedMethod方法 -
synchronized 修饰同步代码块
使用 synchronized 关键字加锁代码块,可以控制只有加锁的部分代码在同一时刻只能被一个线程执行。这种方式更加灵活,可以控制粒度更细。public void someMethod() { synchronized (this) { // 同步代码块 } } ``` synchronized(this) 锁的是someMethod方法 ```java Object lockObj = new Object(); synchronized (lockObj) { // 同步代码块 } ``` synchronized 锁的是lockObj 这个对象
-
synchronized 修饰静态方法/代码块
对于静态方法,使用 synchronized 关键字来实现同步时,锁对象是该静态方法所在的类的 Class 对象。静态方法是属于类而不是对象的,因此需要使用类的 Class 对象来作为锁对象,确保同一时刻只有一个线程可以执行该静态方法。
例如,下面的代码中,synchronized 修饰的静态方法使用了 ClassName.class 作为锁对象:public class MyClass { public static synchronized void staticMethod() { // 同步代码块 } public static void otherMethod() { synchronized (MyClass.class) { // 同步代码块 } } }
在这个例子中,staticMethod() 方法和 otherMethod() 方法都使用了不同的方式来对静态方法进行同步,但实现的效果是一样的,即确保同一时刻只有一个线程可以执行这些静态方法。
-
同步代码块中的wait() 和 notify()
wait() 和 notify() 是 Java 中用于线程间通信的方法,它们必须在同步代码块中使用,即在使用这两个方法时,线程必须持有对象的锁。通常情况下,wait() 方法会使当前线程等待,直到其他线程调用相同对象的 notify() 或 notifyAll() 方法来唤醒它。
下面是 wait() 和 notify() 的基本用法:- wait() 方法会使当前线程等待,并释放对象的锁,直到其他线程调用相同对象的 notify() 或 notifyAll() 方法来唤醒它。
synchronized (obj) { while (condition) { obj.wait(); // 等待,并释放对象的锁 } }
- notify() 方法用于唤醒一个正在等待该对象锁的线程,如果有多个线程在等待,则任意选择一个线程唤醒。
synchronized (obj) { obj.notify(); // 唤醒一个等待线程 }
- notifyAll() 方法用于唤醒所有正在等待该对象锁的线程,让它们竞争获取锁。
synchronized (obj) { obj.notifyAll(); // 唤醒所有等待线程 }
需要注意的是,wait()、notify() 和 notifyAll() 必须在同步块中调用,并且调用这些方法的线程必须拥有对象的锁,否则会抛出 IllegalMonitorStateException 异常。此外,wait() 方法还可以指定等待时间,超过指定时间后自动唤醒,可以通过 wait(long timeout) 或 wait(long timeout, int nanos) 方法实现。
- wait() 方法会使当前线程等待,并释放对象的锁,直到其他线程调用相同对象的 notify() 或 notifyAll() 方法来唤醒它。
4.2 ReentrantLock(可重入锁)
java.util.concurrent.locks.ReentrantLock 是一个可重入锁,它提供了与 synchronized 类似的功能,但具有更多的灵活性。通过 ReentrantLock 可以实现更复杂的同步需求,如定时锁等待、可中断锁等待、公平锁等。使用 ReentrantLock 需要手动获取锁和释放锁,通常需要结合 try-finally 语句来确保锁的释放。
private final ReentrantLock lock = new ReentrantLock();
public void method() {
lock.lock();
try {
// 同步代码块
} finally {
lock.unlock();
}
}
ReentrantLock是可重入锁,它和synchronized一样,一个线程可以多次获取同一个锁。但和synchronized不同的是,ReentrantLock可以尝试获取锁:
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
...
} finally {
lock.unlock();
}
}
上述代码在尝试获取锁的时候,最多等待1秒。如果1秒后仍未获取到锁,tryLock()返回false,程序就可以做一些额外处理,而不是无限等待下去。
所以,使用ReentrantLock比直接使用synchronized更安全,线程在tryLock()失败的时候不会导致死锁。
4.3 ReadWriteLock(读写锁)
java.util.concurrent.locks.ReadWriteLock 是一个读写锁,它允许多个线程同时读取共享资源,但在写操作时会独占资源。读写锁可以提高并发性能,适用于读操作远多于写操作的场景。
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
public void readMethod() {
rwLock.readLock().lock();
try {
// 读操作
} finally {
rwLock.readLock().unlock();
}
}
public void writeMethod() {
rwLock.writeLock().lock();
try {
// 写操作
} finally {
rwLock.writeLock().unlock();
}
}
使用ReadWriteLock可以解决这个问题,它保证:
只允许一个线程写入(其他线程既不能写入也不能读取);
没有写入时,多个线程允许同时读(提高性能)。
4.4 Semaphore(计数信号量)
java.util.concurrent.Semaphore 是一个计数信号量,它可以控制同时访问某个资源的线程数量。Semaphore 通过 acquire() 方法获取许可,release() 方法释放许可,可以用来实现资源池、限流等功能。
private final Semaphore semaphore = new Semaphore(5); // 允许同时访问的线程数量为5
public void method() throws InterruptedException {
semaphore.acquire(); // 获取许可
try {
// 同步代码块
} finally {
semaphore.release(); // 释放许可
}
}
如果要对某一受限资源进行限流访问,可以使用Semaphore,保证同一时间最多N个线程访问受限资源。
5. 线程池的使用
Java语言虽然内置了多线程支持,启动一个新线程非常方便,但是,创建线程需要操作系统资源(线程资源,栈空间等),频繁创建和销毁大量线程需要消耗大量时间。
线程池通过维护一定数量的线程,并管理它们的生命周期,可以更好地控制线程的数量和资源消耗。当有任务需要执行时,线程池会分配一个空闲线程来执行任务,任务执行完毕后,线程不会立即销毁,而是返回线程池中等待下次任务。
使用线程池的好处包括:
- 减少线程创建和销毁的开销:线程池会重复利用已创建的线程,避免频繁创建和销毁线程的开销。
- 更好的管理和控制:线程池可以限制并发线程的数量,避免因为过多线程导致系统资源耗尽或性能下降。
- 提高响应速度:由于线程池中的线程是预先创建好的,可以更快地响应任务的执行请求。
- 更好的资源利用率:通过合理设置线程池的大小,可以更好地利用系统资源,提高系统的整体性能。
Java标准库提供了ExecutorService接口表示线程池
// 创建固定大小的线程池:
ExecutorService executor = Executors.newFixedThreadPool(3);
// 提交任务:
executor.submit(task1);
executor.submit(task2);
executor.submit(task3);
executor.submit(task4);
executor.submit(task5);
因为ExecutorService只是接口,Java标准库提供的几个常用实现类有:
FixedThreadPool:线程数固定的线程池;
CachedThreadPool:线程数根据任务动态调整的线程池;
SingleThreadExecutor:仅单线程执行的线程池。
创建这些线程池的方法都被封装到Executors这个类中。我们以FixedThreadPool为例,看看线程池的执行逻辑:
import java.util.concurrent.*;
public class Main {
public static void main(String[] args) {
// 创建一个固定大小的线程池:
ExecutorService es = Executors.newFixedThreadPool(4);
for (int i = 0; i < 6; i++) {
es.submit(new Task("" + i));
}
// 关闭线程池:
es.shutdown();
}
}
class Task implements Runnable {
private final String name;
public Task(String name) {
this.name = name;
}
@Override
public void run() {
System.out.println("start task " + name);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
System.out.println("end task " + name);
}
}
我们观察执行结果,一次性放入6个任务,由于线程池只有固定的4个线程,因此,前4个任务会同时执行,等到有线程空闲后,才会执行后面的两个任务。
线程池在程序结束的时候要关闭。使用shutdown()方法关闭线程池的时候,它会等待正在执行的任务先完成,然后再关闭。shutdownNow()会立刻停止正在执行的任务,awaitTermination()则会等待指定的时间让线程池关闭。
如果我们把线程池改为CachedThreadPool,由于这个线程池的实现会根据任务数量动态调整线程池的大小,所以6个任务可一次性全部同时执行。
ThreadPoolExecutor创建线程池
实际开发中,通常使用ThreadPoolExecutor创建线程池
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
5,
10,
20,
TimeUnit.MINUTES,
new SynchronousQueue<Runnable>(),
new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
return new Thread();
}
},
new RejectedExecutionHandler() {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
}
});
按照上面参数顺序介绍一下参数:
1、corePoolSize:核心线程数
- 核心线程会一直存活,即使没有任务需要执行
- 当线程数小于核心线程数时,即使有线程空闲,线程池也会优先创建新线程处理
- 设置allowCoreThreadTimeout=true(默认false)时,核心线程会超时关闭
2、queueCapacity:任务队列容量(阻塞队列)
- 当核心线程数达到最大时,新任务会放在队列中排队等待执行
3、maxPoolSize:最大线程数
当线程数>=corePoolSize,且任务队列已满时。线程池会创建新线程来处理任务
当线程数=maxPoolSize,且任务队列已满时,线程池会拒绝处理任务而抛出异常
4、 keepAliveTime:线程空闲时间
当线程空闲时间达到keepAliveTime时,线程会退出,直到线程数量=corePoolSize
如果allowCoreThreadTimeout=true,则会直到线程数量=0
5、allowCoreThreadTimeout:允许核心线程超时
6、rejectedExecutionHandler:任务拒绝处理器
- 两种情况会拒绝处理任务:
- 当线程数已经达到maxPoolSize,切队列已满,会拒绝新任务
- 当线程池被调用shutdown()后,会等待线程池里的任务执行完毕,再shutdown。如果在调用shutdown()和线程池真正shutdown之间提交任务,会拒绝新任务
阿里巴巴对并发编程的规范
-
【强制】获取单例对象需要保证线程安全,其中的方法也要保证线程安全。
说明 : 资源驱动类、工具类、单例工厂类都需要注意。 -
【强制】创建线程或线程池时请指定有意义的线程名称,方便出错时回溯。
正例 :public class TimerTaskThread extends Thread { public TimerTaskThread() { super.setName("TimerTaskThread"); ... } }
-
【强制】线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。
说明 : 使用线程池的好处是减少在创建和销毁线程上所花的时间以及系统资源的开销,解决资 源不足的问题。如果不使用线程池,有可能造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题。 -
【强制】线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
说明 : Executors 返回的线程池对象的弊端如下:
FixedThreadPool 和 SingleThreadPool: 允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。
CachedThreadPool 和ScheduledThreadPool: 允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。 -
【强制】SimpleDateFormat 是线程不安全的类,一般不要定义为 static 变量,如果定义为 static,必须加锁,或者使用 DateUtils 工具类。正例 : 注意线程安全,使用 DateUtils。亦推荐如下处理:
private static final ThreadLocal<DateFormat> df = new ThreadLocal<DateFormat>() { @Override protected DateFormat initialValue() { return new SimpleDateFormat("yyyy-MM-dd"); } };
-
【强制】高并发时,同步调用应该去考量锁的性能损耗。能用无锁数据结构,就不要用锁;能锁区块,就不要锁整个方法体;能用对象锁,就不要用类锁。说明 : 尽可能使加锁的代码块工作量尽可能的小,避免在锁代码块中调用 RPC 方法。
-
【强制】对多个资源、数据库表、对象同时加锁时,需要保持一致的加锁顺序,否则可能会造成死锁。说明 : 线程一需要对表 A、B、C 依次全部加锁后才可以进行更新操作,那么线程二的加锁顺序也必须是 A、B、C,否则可能出现死锁。
-
【强制】并发修改同一记录时,避免更新丢失,需要加锁。要么在应用层加锁,要么在缓存加锁,要么在数据库层使用乐观锁,使用 version 作为更新依据。说明 : 如果每次访问冲突概率小于 20%,推荐使用乐观锁,否则使用悲观锁。乐观锁的重试次数不得小于 3 次。
9.【 强制】多线程并行处理定时任务时,Timer 运行多个 TimeTask 时,只要其中之一没有捕获抛出的异常,其它任务便会自动终止运行,使用 ScheduledExecutorService 则没有这个问题。