02多线程基础知识
目录
1. 线程与进程
进程(Process)
线程(Thread)
2. 并发和并行
并发(Concurrency)
并行(Parallelism)
3. CPU 调度
定义
类型
调度算法
上下文切换
4.线程间的状态流转
线程的主要状态
注意:
5.创建线程的方法
6.线程安全问题
7.线程间的通信
8.Lock对象的介绍和基本使用
Lock 接口的主要方法
Lock 接口的实现类
9.避免忙等待
使用Condition 对象
使用Timer
1. 线程与进程
进程(Process)
-
定义:进程是操作系统进行资源分配和调度的基本单位。每个进程都有自己独立的虚拟地址空间、系统资源(如文件句柄、内存等)和一组系统状态信息。
-
特点:
-
独立性:每个进程都有自己的独立内存空间,不会直接影响其他进程。
-
资源分配:进程拥有独立的系统资源,如文件描述符、内存等。
-
生命周期:进程从创建到终止有一个完整的生命周期,包括创建、就绪、运行、阻塞和终止等状态。
-
线程(Thread)
-
定义:线程是进程内的一个执行单元,是操作系统进行调度的基本单位。一个进程可以包含多个线程,这些线程共享进程的资源,但每个线程有自己的栈和程序计数器。
-
特点:
-
轻量级:相比于进程,线程的创建和切换开销较小。
-
资源共享:同一进程内的线程共享进程的内存和其他资源。
-
并发执行:多个线程可以并发执行,提高程序的响应性和资源利用率。
-
2. 并发和并行
并发(Concurrency)
-
定义:并发是指多个任务在同一时间段内交错执行,但不一定同时执行。操作系统通过时间片调度在多个任务之间快速切换,使这些任务看起来像是同时执行的。
-
特点:
-
时间片调度:操作系统为每个任务分配一个时间片,在这段时间内任务可以执行。
-
上下文切换:任务之间通过上下文切换来交替执行。
-
适用场景:适用于 I/O 密集型任务,如网络请求、文件读写等。
-
并行(Parallelism)
-
定义:并行是指多个任务在同一时刻真正同时执行。这通常需要多核处理器的支持,每个核心可以同时执行一个任务。
-
特点:
-
多核处理器:并行计算依赖于多核处理器,每个核心可以独立执行任务。
-
提高计算效率:并行计算可以显著提高计算密集型任务的执行效率。
-
适用场景:适用于计算密集型任务,如矩阵运算、图像处理等。
-
3. CPU 调度
定义
-
CPU 调度:CPU 调度是操作系统的核心功能之一,负责在多个进程或线程之间分配 CPU 时间。调度器根据一定的算法选择下一个执行的进程或线程,确保系统的高效运行。
类型
-
长期调度(Job Scheduler):负责决定哪些进程可以进入内存并准备执行。通常在进程创建时进行。
-
中期调度(Swapper):负责在内存和磁盘之间交换进程,以释放内存空间。
-
短期调度(CPU Scheduler):负责在就绪队列中的进程或线程之间分配 CPU 时间。是最常见和最重要的调度类型。
调度算法
-
先来先服务(First-Come, First-Served, FCFS):按照进程到达的顺序进行调度。
-
短作业优先(Shortest Job Next, SJN):优先调度预计运行时间最短的进程。
-
优先级调度(Priority Scheduling):根据进程的优先级进行调度,优先级高的进程优先执行。
-
轮转法(Round Robin, RR):每个进程或线程分配一个固定的时间片,时间片结束后切换到下一个进程或线程。
-
多级反馈队列(Multilevel Feedback Queue):结合多种调度策略,根据进程的行为动态调整其优先级和时间片。
上下文切换
-
定义:上下文切换是指从一个进程或线程切换到另一个进程或线程的过程。包括保存当前进程或线程的状态,加载下一个进程或线程的状态。
-
开销:上下文切换会消耗 CPU 时间和内存资源,频繁的上下文切换会影响系统的整体性能。
4.线程间的状态流转
线程的主要状态
-
NEW:线程被创建但是未启动
-
RUNNABLE:线程正在运行或者准备运行
-
BLOCKED:线程被阻塞,等待锁进入同步块或方法
-
WAITING:线程无限等待,需要其他线程调用特定方法唤醒
-
TIMED_WAITING:线程有限等待,指定时间过后恢复或被其他线程唤醒
-
TERMINATED:线程终止,执行完毕或者异常停止
注意:
-
sleep(time)和wait(time)的区别:
-
sleep(time)线程睡眠,睡眠的过程不会释放锁。到时间后自动醒来继续执行。
-
wait(time)线程等待,等待的过程会释放锁,其他线程可以抢,等待过程中被唤醒或者到时间后会进入队列争抢锁。
-
-
wait()和notify():
-
wait()无限等待,会释放锁,需要其他线程调用notify()或则notifyAll()唤醒,被唤醒后进入队列争抢锁
-
notify()一次只能唤醒一条等待的线程,如果是多条线程等待中,随机唤醒一条等待中的线程。
-
notifyAll()唤醒所有等待中的线程。
-
notify()和notifyAll()都不会影响sleep状态的线程
-
-
wait()和notify()的共同点
-
都需要锁对象,所以在同步方法或者块中执行
-
两个方法的调用必须是同一个锁对象调用:理解为同一个锁对象将多条线程分到了一组中,notify就知道唤醒的是本组(同一个同步方法或块)的等待线程
-
5.创建线程的方法
线程的创建方法总共可以分为5种。
-
继承Thread类,通过重写run()方法创建线程
public class ThreadOne extends Thread { public void run() { System.out.println("Thread One is running"); } public static void main(String[] args) { ThreadOne threadOne = new ThreadOne(); //调用start方法,开启线程,jvm自动调用run方法 threadOne.start(); } }
Thread类中的方法:
-
void start():开启线程,jvm自动调用run()方法
-
void run():设置线程任务。Thread重写Runnable中的run()方法
-
String getName():获取线程名字
-
void setName():给线程设置名字
-
static Thread currentThread():获取当前线程对象
-
static void sleep(long millis):线程睡眠,超时后自动醒来继续执行,参数是毫秒
-
void setpriority(int newPriority):设置线程优先级
-
void join() 插队
-
void yield() 礼让
-
-
实现Runnable接口,实现run()方法创建线程
public class ThreadTwo implements Runnable{ @Override public void run() { System.out.println("Thread Two is running"); } public static void main(String[] args) { ThreadTwo threadTwo = new ThreadTwo(); /* Thread(Runnable target) */ Thread thread = new Thread(threadTwo); thread.start(); } }
-
使用Lambda表达式匿名内部类,简化Runnale的创建
public class ThreadThree { public static void main(String[] args) { new Thread(new Runnable(){ @Override public void run() { System.out.println("Thread Three is running"); } },"threadThree").start(); } }
-
使用ExecutorService创建和管理线城池,使用线程池创建
使用
ExecutorService
创建线程池时,newFixedThreadPool
和newCachedThreadPool
是两种常用的工厂方法。然而,这两种线程池在某些情况下可能会因为资源耗尽而导致OutOfMemoryError
(OOM)。注意:实际的开发中不要使用ExecutorService创建线程池,要使用new ThreadPoolExecutor的方式。
public static void main(String[] args) { // 线程池参数 int corePoolSize = 5; // 核心线程数 int maximumPoolSize = 10; // 最大线程数 long keepAliveTime = 60L; // 线程空闲时间 TimeUnit unit = TimeUnit.SECONDS; // 时间单位 BlockingQueue<Runnable> workQueue = new LinkedBlockingQueue<>(100); // 工作队列 ThreadFactory threadFactory = Executors.defaultThreadFactory(); // 线程工厂 RejectedExecutionHandler handler = new ThreadPoolExecutor.DiscardPolicy(); // 拒绝策略 // 创建线程池 ThreadPoolExecutor executor = new ThreadPoolExecutor( corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory, handler ); // 提交大量任务省略... // 关闭线程池 executor.shutdown(); }
-
newFixedThreadPool使用的是无界队列,当队列中的任务增长速度远大于处理的速度,队列会不断增长,导致内存耗尽。应对思路:设置有限队列存放任务
-
newCachedThreadPool使用的是可缓存的线程池,当任务的提交速度大于处理速度,线程池不断创建新线程,导致内存耗尽。应对思路:设置拒绝策略。
-
-
使用FutureTask和Callable创建
FutureTask
和Callable
是用于实现异步计算和返回结果的重要接口和类。public class ThreadFive { public static void main(String[] args) { FutureTask<Integer> futureTask = new FutureTask<>(() -> { int sum = 0; for (int i = 0; i < 100; i++) { sum += i; } return sum; }); new Thread(futureTask).start(); try { System.out.println(futureTask.get()); } catch (InterruptedException | ExecutionException e) { System.out.println("Error: " + e.getMessage()); } } }
6.线程安全问题
模拟妈妈摊10个煎饼儿子吃10个煎饼的场景
public static void main(String[] args) { // 妈妈摊10煎饼 Thread mother = new Thread(() -> { for (int i = 0; i < 10; i++) { System.out.println("妈妈:摊了一个煎饼!"); NUM_PANCAKES++; System.out.println("还剩:" + NUM_PANCAKES + "个煎饼!"); } }); // 儿子吃10煎饼 Thread son = new Thread(() -> { for (int i = 10; i > 0; i--) { System.out.println("儿子:吃了一个煎饼!"); NUM_PANCAKES--; System.out.println("还剩:" + NUM_PANCAKES + "个煎饼!"); } }); mother.start(); son.start(); }
结果:
多线程下对同一个共享资源的访问,会导致诸多线程安全问题:
-
数据竞争
多个线程访问同一内存位置,至少一个在写,没有合适的同步机制保护数据就会导致未定义的行为。
-
竞态条件
多个线程访问和操作共享顺序的操作非原子性(不可中断的操作),那么就会导致不同的顺序不同的结果。
-
死锁
线程间互相等待对方的资源。吃饭需要碗和勺子,一个拿碗一个拿勺。
-
内存可见性
线程更改了共享变量的值,其他线程没有及时同步更新,读取的还是自己的缓存。
-
指令重排序
编译器和处理器可能会重新安排指令的执行顺序,多线程下会影响程序的正确性
7.线程间的通信
在Java中,可以使用wait()
和notify()
方法来实现线程间的同步通信。
用妈妈摊煎饼儿子吃煎饼,模拟线程间通信,同时确保煎饼只能摊一张吃一张的功能。
public class PancakeScenario { // 共享资源:煎饼 private static boolean pancakeReady = false; private static int NUM_PANCAKES_SUM = 0; private static int NUM_PANCAKES_REST = 0; public static void main(String[] args) { // 妈妈:摊煎饼 Thread motherThread = new Thread(() -> { while(true) { synchronized(PancakeScenario.class) { // 有煎饼,妈妈就等待 while (pancakeReady) { try { PancakeScenario.class.wait(); } catch (InterruptedException e) { System.out.println("妈妈:等待失败..."); } } System.out.println("妈妈:烤煎饼中..."); // 煎饼摊好了,妈妈通知儿子 pancakeReady = true; NUM_PANCAKES_REST++; NUM_PANCAKES_SUM++; System.out.println("还剩" + NUM_PANCAKES_REST + "个煎饼"); System.out.println("妈妈摊了" + NUM_PANCAKES_SUM + "个煎饼"); PancakeScenario.class.notify(); } } }, "motherThread"); // 儿子:吃煎饼 Thread childThread = new Thread(() -> { while(true) { synchronized(PancakeScenario.class) { // 没煎饼,儿子就等待 while (!pancakeReady) { try { PancakeScenario.class.wait(); } catch (InterruptedException e) { System.out.println("儿子:等待失败..."); } } System.out.println("儿子:吃煎饼中..."); // 煎饼吃完了,儿子通知妈妈 pancakeReady = false; NUM_PANCAKES_REST--; System.out.println("还剩" + NUM_PANCAKES_REST + "个煎饼"); System.out.println("儿子吃了" + NUM_PANCAKES_SUM + "个煎饼"); PancakeScenario.class.notify(); } } }, "childThread"); motherThread.start(); childThread.start(); } }
8.Lock对象的介绍和基本使用
在Java中,Lock
接口提供了比内置的 synchronized
关键字更灵活的锁定机制。Lock
接口及其相关类位于 java.util.concurrent.locks
包中,提供了一系列高级功能,如公平锁、非阻塞锁、可中断锁等。
Lock
接口的主要方法
-
void lock()
:获取锁。如果锁不可用,当前线程将被阻塞,直到锁可用。 -
void lockInterruptibly()
:获取锁,如果锁不可用,当前线程将被阻塞,直到锁可用或被中断。 -
boolean tryLock()
:尝试获取锁。如果锁可用,则立即返回true
;如果锁不可用,则立即返回false
。 -
boolean tryLock(long time, TimeUnit unit)
:尝试获取锁,但在指定的等待时间内如果锁不可用,则返回false
。 -
void unlock()
:释放锁。
Lock
接口的实现类
-
ReentrantLock
:最常用的Lock
实现,支持重入。这意味着同一个线程可以多次获取同一个锁,而不会导致死锁。 -
ReentrantReadWriteLock
:读写锁,允许多个读取者同时访问资源,但写入者独占资源。 -
StampedLock
:提供乐观读锁、写锁和读锁,适用于高性能读多写少的场景
用lock改造摊煎饼场景:
public class PancakeScenarioUpgrade { // 共享资源:煎饼 private static boolean pancakeReady = false; // 锁对象 private static final Lock lock = new ReentrantLock(); public static void main(String[] args) { // 妈妈线程:负责摊煎饼 Thread motherThread = new Thread(() -> { while (true) { lock.lock(); try { while (pancakeReady) { // 如果已经有煎饼了,妈妈就等待 try { lock.unlock(); Thread.sleep(1000); // 模拟等待时间 } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.lock(); } } // 摊煎饼 pancakeReady = true; System.out.println("妈妈摊了一个煎饼"); } finally { lock.unlock(); } // 模拟摊煎饼的时间 try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } }); // 儿子线程:负责吃煎饼 Thread sonThread = new Thread(() -> { while (true) { lock.lock(); try { while (!pancakeReady) { // 如果没有煎饼,儿子就等待 try { lock.unlock(); Thread.sleep(1000); // 模拟等待时间 } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.lock(); } } // 吃煎饼 pancakeReady = false; System.out.println("儿子吃了煎饼"); } finally { lock.unlock(); } // 模拟吃煎饼的时间 try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } }); // 启动线程 motherThread.start(); sonThread.start(); } }
9.避免忙等待
在改造的场景中,idea有一个提示信息:
// 在循环中使用Thread.sleep()可能会忙等待 Call to 'Thread.sleep()' in a loop, probably busy-waiting
忙等待是指在一个循环中不断检查某个条件,直到该条件满足为止,而在此过程中线程不会放弃 CPU 时间片,也不会进入休眠状态。这种方式通常用于短时间的等待,或者在高实时性要求的场景中。它会导致CPU持续占用和性能损耗。
-
使用
Condition
对象
可以使用Condition
对象,提供了更精确的等待和通知机制,避免 Thread.sleep()
的精度问题。
public class PancakeScenario { // 共享资源:煎饼 private static boolean pancakeReady = false; // 锁对象 private static final Lock lock = new ReentrantLock(); // 条件对象 private static final Condition pancakeReadyCondition = lock.newCondition(); public static void main(String[] args) { // 妈妈线程:负责摊煎饼 Thread motherThread = new Thread(() -> { while (true) { lock.lock(); try { while (pancakeReady) { // 如果已经有煎饼了,妈妈就等待 pancakeReadyCondition.await(); } // 摊煎饼 System.out.println("妈妈摊了一个煎饼"); pancakeReady = true; // 通知儿子可以吃煎饼了 pancakeReadyCondition.signal(); } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.unlock(); } // 模拟摊煎饼的时间 try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } }); // 儿子线程:负责吃煎饼 Thread sonThread = new Thread(() -> { while (true) { lock.lock(); try { while (!pancakeReady) { // 如果没有煎饼,儿子就等待 pancakeReadyCondition.await(); } // 吃煎饼 System.out.println("儿子吃了煎饼"); pancakeReady = false; // 通知妈妈可以摊新的煎饼了 pancakeReadyCondition.signal(); } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.unlock(); } // 模拟吃煎饼的时间 try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } }); // 启动线程 motherThread.start(); sonThread.start(); } }
-
使用Timer
还是摊煎饼吧,儿子每隔10秒就跑去看煎饼是否摊好。
public static void main(String[] args) { Thread son = new Thread(() -> { while (true) { try { //看煎饼好了没 checkPancakeStatus(); Thread.sleep(1000L * 10); } catch (Exception e) { //print the error log e.printStackTrace(); } } }); son.start(); }
如果checkPancakeStatus方法抛出了异常就会跳过sleep(),那就没办法休眠,循环持续执行,在这个基础上,如果捕获异常打印了日志,还会导致日志撑爆磁盘。
还有一个要注意的坑,线程在sleep的过程中并不会释放所持有的锁,这会导致严重的并发问题,甚至是死锁。
推荐可以使用使用jdk自带的
java.util.Timer
解决:public static void main(String[] args) { Timer timer = new Timer(); timer.schedule(new TimerTask(){ @Override public void run() { try { //看煎饼好了没 checkPancakeStatus(); Thread.sleep(1000L * 10); } catch (Exception e) { //print the error log e.printStackTrace(); } } }, 1000L * 10, 1000L * 10); }