进程和线程
进程和线程
- 操作系统中运⾏多个软件
- ⼀个运⾏中的软件可能包含多个进程(一个应用至少有一个进程)
- ⼀个运⾏中的进程可能包含多个线程
- 进程是资源分配的最小单位,线程是CPU调度的最小单位
CPU 线程和操作系统线程
CPU 线程
-
多核 CPU 的每个核各⾃独⽴运⾏,因此每个核⼀个线程
-
「四核⼋线程」:CPU 硬件⽅在硬件级别对 CPU 进⾏了⼀核多线程的⽀持(本质上依然是每个核⼀个线程)
-
操作系统线程:操作系统利⽤时间分⽚的⽅式,把 CPU 的运⾏拆分给多条运⾏逻辑,即为操作系统的线程
-
单核CPU 也可以运⾏多线程操作系统
线程是什么
按代码顺序执⾏下来,执⾏完毕就结束的⼀条线
- UI 线程为什么不会结束?因为它在初始化完毕后会执⾏死循环,循环的内容是刷新界⾯,真正会导致卡死的是这个循环中的消息,如果这个消息长时间没有完成处理,就是会导致卡顿。
启动一个线程的两种方式:
/**
* 使用 Thread 类来定义工作
*/
static void thread() {
Thread thread = new Thread() {
@Override
public void run() {
System.out.println("Thread started!");
}
};
thread.start();
thread.run(); // 会导致线程直接开始工作。
}
/**
* 使用 Runnable 类来定义工作
*/
static void runnable() {
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("Thread with Runnable started!");
}
};
Thread thread = new Thread(runnable);
thread.start();
}
上面两种方式最终都会执行到:
public void run() {
if (this.target != null) {
this.target.run();
}
}
两种写法的差别就是第二种的runnable可以重用。
注意:thread.run() 会导致线程直接开始工作。
不过现在很少使用上边这种直接启动Thread的方式,一般都用线程池来做。
Callable 和 Future
与Runnable不同,Callable可以返回值,由Future来接收。
Callable
-
定义与作用:
Callable<V>是一个泛型接口,定义了一个任务,该任务可以在执行完毕后返回一个结果,并且能够抛出异常。与Runnable接口相比,Callable的特点是可以返回结果和抛出检查异常。 -
主要方法:
V call() throws Exception
这是Callable接口中唯一的方法,用于执行任务并返回一个结果。如果任务执行过程中出现问题,可以抛出异常。
-
使用场景:
当需要在线程中执行一个任务并返回一个计算结果,或者在任务执行过程中可能发生异常时,通常会选择使用Callable。
Future
-
定义与作用:
Future<V>接口代表一个异步计算的结果。它提供了一些方法,可以用来检查任务是否完成、等待任务完成、取消任务以及获取任务的结果。通常与Callable配合使用。 -
主要方法:
boolean cancel(boolean mayInterruptIfRunning)
尝试取消任务的执行,如果任务已经完成或者无法取消则返回false。boolean isCancelled()
检查任务是否在正常完成前被取消。boolean isDone()
检查任务是否已经完成(无论是正常完成、异常结束还是被取消)。V get()
获取任务的结果。如果任务尚未完成,该方法会阻塞等待任务完成后返回结果。V get(long timeout, TimeUnit unit)
在给定的超时时间内等待任务完成,超时后抛出TimeoutException。
-
使用场景:
Future常用于从线程池(例如通过ExecutorService.submit(Callable)提交任务)获取任务的执行结果。可以通过Future检查任务状态、取消任务或获取结果,从而实现异步编程模式。
Callable 与 Future 的结合使用
-
任务提交:
通常,先创建一个实现了Callable接口的任务,然后将任务提交给一个线程池(例如ExecutorService),提交时返回一个Future对象。ExecutorService executor = Executors.newFixedThreadPool(2); Callable<Integer> task = () -> { // 模拟计算任务 TimeUnit.SECONDS.sleep(2); return 123; }; Future<Integer> future = executor.submit(task); -
获取结果:
调用future.get()会阻塞当前线程,直到任务执行完成并返回结果。try { Integer result = future.get(); System.out.println("Task result: " + result); } catch (InterruptedException | ExecutionException e) { e.printStackTrace(); } -
取消任务:
如果需要取消任务执行,可以调用future.cancel(true)。取消后的任务无法再获取正确的结果,调用get()时可能会抛出异常。future.cancel(true);
另一个示例:
static void callable() {
Callable<String> callable = new Callable<String>() {
@Override
public String call() {
try {
Thread.sleep(1500);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "Done!";
}
};
ExecutorService executor = Executors.newCachedThreadPool();
Future<String> future = executor.submit(callable); // 与Runnable不同,Callable可以返回值,由Future来接收。
while (true) {
if (future.isDone()) {
try {
String result = future.get();
System.out.println("result: " + result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
break;
}
}
}
总结
-
Callable:
- 是一个任务接口,可以返回计算结果并抛出异常。
- 用于定义需要返回结果的并发任务。
-
Future:
- 表示异步计算的结果。
- 提供了检查任务是否完成、等待任务完成、取消任务以及获取任务结果的方法。
通过将 Callable 和 Future 结合使用,可以方便地实现异步任务的提交与结果获取,同时能够处理任务执行中的异常和任务取消等情况。这种机制使得 Java 的多线程编程更加灵活和强大。
几种线程池
java.util.concurrent 包提供了多种线程池实现,常用的四种线程池通常指通过 Executors 工厂方法创建的以下几种:
-
Cached Thread Pool
-
创建方法:
Executors.newCachedThreadPool() -
特点:
- 线程数量可动态调整,适用于任务执行时间较短、执行频率较高的场景。
- 当线程空闲超过一定时间(默认 60 秒)后会被回收。
- 适合执行大量短期异步任务,但在任务量过大时可能会创建过多线程,消耗过多资源。
-
-
Fixed Thread Pool
-
创建方法:
Executors.newFixedThreadPool(int nThreads) -
特点:
- 拥有固定数量的线程,无论任务量多大,线程池内的线程数都不会超过设置的数量。
- 适用于任务数量较为稳定,且需要限制并发线程数的场景。
- 如果所有线程都在忙,后续任务会进入等待队列,直到有线程空闲。
-
-
Single Thread Executor
-
创建方法:
Executors.newSingleThreadExecutor() -
特点:
- 只有一个工作线程来执行任务,所有提交的任务都会按照先入先出的顺序执行。
- 适用于需要保证任务顺序执行的场景。
- 如果线程异常终止,线程池会自动创建一个新的线程来替代它,保证任务连续执行。
-
-
Scheduled Thread Pool
-
创建方法:
Executors.newScheduledThreadPool(int corePoolSize) -
特点:
- 用于执行延时任务或者周期性任务。
- 提供了
schedule()、scheduleAtFixedRate()、scheduleWithFixedDelay()等方法,可以灵活地设置任务的执行延时和周期。 - 适合于定时任务调度场景,如定时数据采集、定时清理等。
-
注意事项
- 线程池参数调优:
尽管Executors提供的工厂方法方便快捷,但在实际项目中,有时需要对线程池参数进行细致的调优,此时可以直接使用ThreadPoolExecutor构造方法来创建线程池,并设置合适的核心线程数、最大线程数、任务队列以及拒绝策略。ThreadPoolExecutor 主要有以下几个参数:
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
-
corePoolSize :核心池的大小,如果调用了prestartAllCoreThreads()或者prestartCoreThread()方法,会直接预先创建corePoolSize的线程,否则当有任务来之后,就会创建一个线程去执行任务,当线程池中的线程数目达到corePoolSize后,就会把到达的任务放到缓存队列当中;这样做的好处是,如果任务量很小,那么甚至就不需要缓存任务,corePoolSize的线程就可以应对;
-
maximumPoolSize:线程池最大线程数,表示在线程池中最多能创建多少个线程,如果运行中的线程超过了这个数字,那么相当于线程池已满,新来的任务会使用RejectedExecutionHandler 进行处理;
-
keepAliveTime:表示线程没有任务执行时最多保持多久时间会终止,然后线程池的数目维持在corePoolSize 大小;
-
unit:参数keepAliveTime的时间单位;
-
workQueue:一个阻塞队列,用来存储等待执行的任务,如果当前对线程的需求超过了corePoolSize大小,才会放在这里;
-
threadFactory:线程工厂,主要用来创建线程,比如可以指定线程的名字;
-
handler:如果线程池已满,新的任务的处理方式
-
资源管理:
使用完线程池后,记得调用shutdown()或shutdownNow()方法关闭线程池,避免资源泄露。 -
拒绝策略:
当线程池及其队列满员时,新的任务会根据设置的拒绝策略来处理,常见策略包括:AbortPolicy:默认策略,抛出RejectedExecutionException。CallerRunsPolicy:由提交任务的线程执行该任务。DiscardPolicy:直接丢弃任务,不抛异常。DiscardOldestPolicy:丢弃队列中等待最久的任务,再尝试提交当前任务。
通过合理选择和配置线程池,可以在保证系统性能的同时,实现高效的并发任务处理。
shutdown() 和 shutdownNow()的区别:
-
任务接受情况:
shutdown():不再接受新任务,但让已提交任务继续执行。shutdownNow():不仅拒绝新任务,还会尝试中断正在执行的任务,并返回未开始执行的任务列表。
-
执行结果:
shutdown():线程池在所有任务完成后平缓关闭。shutdownNow():线程池试图尽快关闭,但可能会中断当前任务,导致部分任务没有正常完成。
线程同步与线程安全
synchronized 的本质
- 保证⽅法内部或代码块内部资源(数据)的互斥访问。即同⼀时间、由同⼀个 Monitor 监视的代码,最多只能有⼀个线程在访问。
- 保证线程之间对监视资源的数据同步。即,任何线程在获取到 Monitor 后的第⼀时间,会先将共享内存中的数据复制到⾃⼰的缓存中;任何线程在释放 Monitor 的第⼀时间,会先将缓存中的数据复制到共享内存中。
volatile
- 保证加了 volatile 关键字的字段的操作具有同步性,以及对 long 和 double 的操作的原 ⼦性(long double 原⼦性这个简单说⼀下就⾏)。因此 volatile 可以看做是简化版的synchronized。
- volatile 只对基本类型 (byte、char、short、int、long、float、double、boolean) 的赋值操作 和对象的引⽤赋值操作有效,你要修改 User.name 是不能保证同步的。
- volatile 依然解决不了 ++ 的原⼦性问题。
java.util.concurrent.atomic 包:下⾯有 AtomicInteger AtomicBoolean 等类,作⽤和 volatile 基本⼀致,可以看做是通⽤版的 volatile。
Lock / ReentrantReadWriteLock
同样是「加锁」机制。但使⽤⽅式更灵活,同时也更麻烦⼀些。
Lock lock = new ReentrantLock();
...
lock.lock();
try {
x++;
} finally {
lock.unlock();
}
finally 的作⽤:保证在⽅法提前结束或出现 Exception 的时候,依然能正常释放锁。
⼀般并不会只是使⽤ Lock ,⽽是会使⽤更复杂的锁,例如 ReadWriteLock
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();
private void count() {
writeLock.lock();
try {
x++;
} finally {
writeLock.unlock();
}
}
private void print(int time) {
readLock.lock();
try {
System.out.print(x + " ");
} finally {
readLock.unlock();
}
}
@Override
public void runTest() {
}
线程安全问题的本质
-
在多个线程访问共同的资源时,在某⼀个线程对资源进⾏写操作的中途(写⼊已经开始,但还没结 束),其他线程对这个写了⼀半的资源进⾏了读操作,或者基于这个写了⼀半的资源进⾏了写操 作,导致出现数据错误。
-
锁机制的本质:通过对共享资源进行访问限制,让同⼀时间只有⼀个线程可以访问资源,保证了数据的准确性。
-
不论是线程安全问题,还是针对线程安全问题所衍⽣出的锁机制,它们的核心都在于共享的资源, ⽽不是某个⽅法或者某⼏⾏代码。
最后简单说说ReentrantReadWriteLock
ReentrantReadWriteLock不知道大家熟悉吗?其实在实际的项目中用的比较少,反正我所在的项目没有用到过。
ReentrantReadWriteLock称为读写锁,它提供一个读锁,支持多个线程共享同一把锁。它也提供了一把写锁,是独占锁,和其他读锁或者写锁互斥,表明只有一个线程能持有锁资源。通过两把锁的协同工作,能够最大化的提高读写的性能,特别是读多写少的场景,而往往大部分的场景都是读多写少的。ReentrantReadWriteLock实现了ReadWriteLock接口,可以获取到读锁(共享锁),写锁(独占锁)。同时,通过构造方法可以创建锁本身是公平锁还是非公锁。 读写锁机制:
| 读锁 | 写锁 | |
|---|---|---|
| 读锁 | 共享 | 互斥 |
| 写锁 | 互斥 | 互斥 |
线程进入读锁的前提条件:
- 没有其他线程的写锁
- 没有写请求,或者有写请求但调用线程和持有锁的线程是同一个线程
进入写锁的前提条件:
- 没有其他线程的读锁
- 没有其他线程的写锁
使用示例:
private int x = 0;
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();
private void count() {
writeLock.lock();
try {
x++;
} finally {
writeLock.unlock();
}
}
private void print(int time) {
readLock.lock();
try {
System.out.print(x + " ");
} finally {
readLock.unlock();
}
}
@Override
public void runTest() {
}
上述示例实现了多线程可以同时度,但是不能同时读写的操作。读的时候用readLock,写的时候用writeLock就能够实现需要的效果。