Java强化:多线程及线程池
一、多线程的基础概念:
- 多线程的概念:线程是程序内部的一条执行流程,由
Thread
对象代表;多线程是从软硬件上实现多条执行流程的技术,由CPU负责调度,应用场景包括12306购票、百度网盘的上传下载、消息通信等。 - 创建多线程的第一种方式:通过继承
Thread
类实现,步骤为:定义一个子类继承Thread
类;重写Thread
类的run
方法,在该方法中编写线程要执行的任务代码;创建该子类的实例对象;调用实例对象的start
方法启动线程。 - 第一种创建方式的优缺点:优点是编码简单;缺点是线程类已继承
Thread
类,无法再继承其他类,不利于功能扩展(但该缺点并非绝对,需根据实际需求判断)。 - 注意事项:启动线程必须调用
start
方法,直接调用run
方法会将线程对象当作普通对象处理,仍为单线程;不要将主线程的任务放在启动子线程之前,否则主线程会先执行完,无法体现多线程同时执行的效果。
二、多线程实现Runnable
接口创建:
-
多线程的第二种创建方式:实现
Runnable
接口
步骤为:- 定义一个线程任务类实现
Runnable
接口; - 重写
Runnable
接口的run
方法,编写线程要执行的任务代码,示例代码如下:// 定义线程任务类实现Runnable接口 class MyRunnable implements Runnable {@Overridepublic void run() {// 线程要执行的任务代码for (int i = 0; i < 5; i++) {System.out.println("子线程输出:" + i);}} }
- 创建该线程任务类的实例对象;
- 将该实例对象作为参数交给
Thread
对象,包装成线程对象; - 调用线程对象的
start
方法启动线程,示例代码如下:public class ThreadDemo2 {public static void main(String[] args) {// 创建线程任务对象Runnable task = new MyRunnable();// 将任务对象包装成线程对象Thread t1 = new Thread(task);// 启动线程t1.start();// 主线程任务for (int i = 0; i < 5; i++) {System.out.println("主线程输出:" + i);}} }
- 定义一个线程任务类实现
-
第二种创建方式的优缺点
- 优点:线程任务类仅实现接口,可继续继承其他类和实现其他接口,扩展性强,对类的功能阉割少。
- 缺点:需要额外创建一个任务对象,且线程执行完的
run
方法不能直接返回结果。
-
第二种创建方式的简化写法:匿名内部类
无需定义Runnable
接口的实现类,直接创建Runnable
匿名内部类对象作为线程任务对象,再包装成线程对象并启动。可进一步简化为链式编程,甚至利用Lambda表达式简化(因Runnable
是函数式接口),示例代码如下:// 匿名内部类写法 new Thread(new Runnable() {@Overridepublic void run() {for (int i = 0; i < 5; i++) {System.out.println("子线程一输出:" + i);}} }).start();// Lambda表达式简化写法 new Thread(() -> {for (int i = 0; i < 5; i++) {System.out.println("子线程二输出:" + i);} }).start();
三、多线程实现Callable接口创建:
- 多线程的第三种创建方式:实现Callable接口:步骤为定义一个线程任务类实现Callable接口,并指定泛型类型(该类型为线程执行完返回的结果类型);重写Callable接口的call方法,编写线程要执行的任务代码,并在方法中返回结果;创建该线程任务类的实例对象;将该实例对象作为参数交给FutureTask对象,用于接收线程执行后的结果;将FutureTask对象作为参数交给Thread对象,包装成线程对象;调用线程对象的start方法启动线程;通过FutureTask对象的get方法获取线程执行后的结果。
- 第三种创建方式的示例代码:
// 定义线程任务类实现Callable接口 class MyCallable implements Callable<Integer> {@Overridepublic Integer call() throws Exception {int sum = 0;for (int i = 0; i <= 100; i++) {sum += i;}return sum;} }// 测试类 public class ThreadDemo3 {public static void main(String[] args) throws ExecutionException, InterruptedException {// 创建线程任务对象Callable<Integer> callable = new MyCallable();// 创建FutureTask对象接收结果FutureTask<Integer> futureTask = new FutureTask<>(callable);// 包装成线程对象并启动new Thread(futureTask).start();// 获取线程执行结果Integer result = futureTask.get();System.out.println("结果是:" + result);} }
- 第三种创建方式的优缺点:优点是线程任务类仅实现接口,可继续继承其他类和实现其他接口,扩展性强;call方法可以直接返回结果,方便获取线程执行后的结果。缺点是需要额外创建任务对象和FutureTask对象,编码相对复杂;get方法可能会阻塞主线程,需要处理异常。
- 三种创建方式的对比与选择:继承Thread类方式编码简单,但扩展性差;实现Runnable接口方式扩展性强,但无法直接返回结果;实现Callable接口方式扩展性强且能返回结果,但编码复杂。实际开发中,根据是否需要返回结果和扩展性需求选择,优先考虑实现接口的方式,需要返回结果时选Callable,否则选Runnable。
四、多线程的常用方法
-
线程名称相关方法
- 获取线程名称:
getName()
- 设置线程名称:
setName()
- 说明:线程默认名称为“Thread-索引”,主线程默认名称为“main”。设置名称需在启动线程之前,也可通过有参构造器设置。
- 获取线程名称:
-
获取当前线程对象方法
- 方法:
currentThread()
- 说明:这是Thread类的静态方法,用于获取当前正在执行的线程对象,哪个线程调用该方法,就返回哪个线程的对象。
- 方法:
-
线程休眠方法
- 方法:
sleep(long millis)
- 说明:这是Thread类的静态方法,作用是让当前执行的线程进入休眠状态,参数为休眠时间(单位为毫秒),时间到后线程继续执行,需处理异常。
- 方法:
-
线程插队方法
- 方法:
join()
- 说明:该方法用于让调用此方法的线程先执行完毕,即实现线程插队,待该线程执行完后,其他线程再继续执行,可用于控制线程执行顺序。
- 方法:
-
其他线程方法说明
- 如yield()、interrupt()、线程守护、线程优先级等方法在开发中较少使用,入门阶段掌握上述方法即可,后续用到再详细讲解。
-
线程安全问题的定义:指多个线程同时操作同一个共享资源,且对该资源进行修改时,可能出现的业务安全问题。
-
线程安全问题的示例:以小明和小红从共同账户(初始余额10万元)同时取10万元为例,可能出现两人都成功取出10万元,最终账户余额变为-10万元的安全问题,造成银行亏损。
-
线程安全问题出现的原因
- 存在多个线程同时执行;
- 这些线程同时访问同一个共享资源;
- 线程对共享资源进行了修改(仅读取资源不会产生安全问题)。
-
模拟线程安全问题的设计思路:采用面向对象思想设计
- 创建账户类(Account):描述共享账户,包含卡号、余额等信息,提供取钱方法;
- 创建取钱线程类(DrawThread):继承Thread类,持有账户对象,重写run方法实现取钱逻辑;
- 创建线程对象:代表小明和小红,传入同一个账户对象并启动,模拟同时取钱场景。
-
模拟线程安全问题的代码实现
- 账户类(Account)
public class Account {private String cardId;private double money;// 构造器、getter、setter方法(略)// 取钱方法public void drawMoney(double money) {// 获取当前取钱的线程名称String name = Thread.currentThread().getName();// 判断余额是否充足if (this.money >= money) {System.out.println(name + "吐出" + money + "元,取钱成功");// 更新余额this.money -= money;System.out.println(name + "取钱后,余额剩余:" + this.money + "元");} else {System.out.println(name + "取钱失败,余额不足");}} }
- 取钱线程类(DrawThread)
public class DrawThread extends Thread {private Account account;// 有参构造器,接收账户对象和线程名称public DrawThread(Account account, String name) {super(name);this.account = account;}@Overridepublic void run() {// 调用账户的取钱方法,取10万元account.drawMoney(100000);} }
- 主类(测试类)
public class ThreadDemo1 {public static void main(String[] args) {// 创建共享账户对象,初始余额10万元Account account = new Account("ICBC-110", 100000);// 创建小明和小红的线程对象,传入同一个账户new DrawThread(account, "小明").start();new DrawThread(account, "小红").start();} }
- 账户类(Account)
-
模拟结果及分析:代码大概率会出现线程安全问题(两人都取出10万元)。原因是两个线程可能同时判断余额充足(均为10万元),随后先后执行取钱和更新余额操作,导致账户余额异常。设计时将“更新余额”放在“吐出钱”之后,目的是给其他线程留下判断余额的时间窗口,更易模拟出问题。
五、解决线程安全问题:
- 线程同步的核心思想:让多个线程先后依次访问共享资源,避免线程安全问题。通过加锁机制,每次只允许一个线程加锁并访问共享资源,访问完毕后自动解锁,其他线程才能加锁进入。
- 同步代码块:使用
synchronized
关键字,将访问共享资源的核心代码块上锁。需要声明一个锁对象,该对象对于所有竞争的线程必须是同一把锁。实例方法建议用this
作为锁,静态方法建议用字节码对象(类名.class)作为锁,以避免锁的范围过大影响性能。 - 同步方法:在方法上使用
synchronized
关键字,将整个方法上锁。其原理与同步代码块类似,实例方法默认用this
作为锁,静态方法默认用类名.class作为锁。同步方法锁的范围更广,可读性好但性能相对较差,同步代码块锁的范围小,性能更优。 - Lock锁:JDK5提供的锁定操作,通过
ReentrantLock
创建具体的锁对象,使用lock()
方法上锁,unlock()
方法解锁。建议将unlock()
放在finally
块中,确保无论是否出现异常都能解锁,且锁对象建议用final
修饰以防止被篡改。
六、线程池的相关知识:
- 线程池的概念:线程池是一种可以复用线程的技术,能避免频繁创建线程导致的内存占用过多和CPU负担过重等问题。
- 线程池的工作原理:线程池创建后,新任务会被放入任务队列,由固定的工作线程处理,线程处理完当前任务后可处理后续任务,从而控制线程数量,实现线程复用,提高系统性能。
- 创建线程池的方式:
- 第一种是使用
ExecutorService
的实现类ThreadPoolExecutor
,通过其有参构造器指定七个参数创建,七个参数分别是核心线程数量、最大线程数量、临时线程存活时间、时间单位、任务队列、线程工厂、任务拒绝策略。 - 第二种是使用工具类
Executors
,调用其静态方法返回不同的线程池对象(后续课程讲解)。
- 第一种是使用
- 线程池处理Runnable任务:通过
execute
方法执行Runnable任务,线程池会复用线程处理任务,还介绍了关闭线程池的shutdown
(等所有任务执行完毕后关闭)和shutdownNow
(立即关闭,不管任务是否执行完毕)方法。 - 临时线程的创建时机:新任务提交时,核心线程都在忙,任务队列已满,且可以创建临时线程时,才会创建临时线程。
- 任务拒绝策略:当核心线程、临时线程都在忙,任务队列也满了,新任务过来时会触发拒绝策略,常见的有直接抛异常、丢弃任务不抛异常、丢弃等待最久的任务并加入新任务、由主线程执行任务等。
- 线程池处理Callable任务:通过
submit
方法提交Callable任务,会返回一个Future
对象,可通过该对象获取任务执行后的结果,线程池同样会复用线程处理任务。
七、Executors 工具类创建线程池
-
Executors工具类创建线程池
- Executors是Java提供的线程池工具类,通过静态方法返回不同特点的线程池,具体如下:
newFixedThreadPool(n)
:返回固定线程数量的线程池,核心线程数和最大线程数均为n,无临时线程,任务队列不限制长度。newSingleThreadExecutor()
:返回单个线程的线程池,仅1个核心线程,线程若因异常死亡会自动补充新线程。newCachedThreadPool()
:返回可伸缩的线程池,线程数量随任务增加而增加,空闲60秒的线程会被回收。newScheduledThreadPool(n)
:返回可执行定时/延时任务的线程池,核心线程数为n,属于ScheduledExecutorService
类型。
- Executors是Java提供的线程池工具类,通过静态方法返回不同特点的线程池,具体如下:
-
Executors工具类底层原理
- 底层通过调用
ThreadPoolExecutor
的构造器(含7个核心参数)实现线程池创建,本质是对线程池创建过程的封装。 - 示例:
newFixedThreadPool(3)
的底层会将核心线程数、最大线程数均设为3,临时线程存活时间为0,任务队列采用无界链表(不限制任务数量)。
- 底层通过调用
-
Executors工具类的风险
- 阿里巴巴开发手册明确不建议在大型系统中使用,主要风险包括:
newFixedThreadPool()
和newSingleThreadExecutor()
:任务队列无界,大量任务堆积可能导致OOM(内存溢出)。newCachedThreadPool()
和newScheduledThreadPool()
:线程数量可无限增长,可能因线程过多导致资源耗尽。
- 建议:通过
ThreadPoolExecutor
手动配置7个参数(核心线程数、最大线程数、空闲时间等),灵活控制资源占用,小型系统可酌情使用Executors。
- 阿里巴巴开发手册明确不建议在大型系统中使用,主要风险包括:
-
线程池参数配置依据
- 核心线程数和最大线程数的配置需结合业务类型:
- 计算密集型(如大量循环、数学运算):线程数建议为CPU核心数或核心数+1,减少上下文切换开销。
- IO密集型(如网络请求、磁盘读写):线程数建议为CPU核心数的2倍,利用CPU等待IO的时间提升效率。
- 核心线程数和最大线程数的配置需结合业务类型:
-
进程与线程的关系
- 进程:正在运行的程序,动态占用CPU、内存、网络等资源。
- 线程:进程内的执行单元,一个进程可包含多个线程,线程共享进程资源。
-
并发与并行的概念
- 并发:CPU通过分时轮询为多个线程服务,因切换速度极快,产生“同时执行”的错觉(如单核CPU处理多线程)。
- 并行:同一时刻多个线程被CPU调度执行,依赖CPU核心数(如4核CPU可并行处理4个线程)。
- 实际多线程执行是并发与并行的结合(如20核CPU在6800个线程中,每20个线程并行执行,整体通过并发切换处理所有线程)。
八、抢红包游戏案例
-
案例需求介绍
某企业100名员工(工号1-100)参与抢红包活动,需发出200个红包:- 小红包(1-30元)占80%(160个),大红包(31-100元)占20%(40个)。
- 需模拟抢红包过程并输出详情,活动结束时提示“活动结束”,最终可对员工总金额降序排序(视频中主要完成前两步)。
-
开发核心思路
100名员工对应100个线程,竞争同一批红包资源(200个红包存于集合中),通过多线程同步机制保证线程安全。 -
关键步骤与方法实现
-
步骤1:生成红包集合
使用Random
类生成随机金额,按比例划分小红包和大红包,存入List
集合。// 生成200个红包的方法 public static List<Integer> generateRedPackets() {List<Integer> redPackets = new ArrayList<>();Random random = new Random();// 生成160个小红包(1-30元)for (int i = 0; i < 160; i++) {int amount = random.nextInt(30) + 1; // 1-30redPackets.add(amount);}// 生成40个大红包(31-100元)for (int i = 0; i < 40; i++) {int amount = random.nextInt(70) + 31; // 31-100redPackets.add(amount);}return redPackets; }
-
步骤2:定义线程类(模拟员工抢红包)
线程类实现Runnable
接口或继承Thread
类,通过构造器接收红包集合和员工工号,重写run()
方法实现抢红包逻辑。// 抢红包线程类 class GrabRedPacketThread extends Thread {private List<Integer> redPackets; // 共享的红包集合public GrabRedPacketThread(List<Integer> redPackets, String name) {super(name); // 线程名即员工工号this.redPackets = redPackets;}@Overridepublic void run() {while (true) {synchronized (redPackets) { // 同步代码块保证线程安全if (redPackets.isEmpty()) {System.out.println("活动结束");break;}// 随机获取一个红包并移除int index = new Random().nextInt(redPackets.size());int amount = redPackets.remove(index);System.out.println(Thread.currentThread().getName() + "抢到" + amount + "元");}// 休眠10ms模拟抢红包间隔try {Thread.sleep(10);} catch (InterruptedException e) {e.printStackTrace();}}} }
-
步骤3:启动线程与执行流程
在主方法中生成红包集合,创建100个线程(员工)并启动,线程竞争抢红包直至红包耗尽。public static void main(String[] args) {List<Integer> redPackets = generateRedPackets(); // 生成红包// 创建100个员工线程并启动for (int i = 1; i <= 100; i++) {new GrabRedPacketThread(redPackets, "员工" + i).start();} }
-
-
核心技术点
- 线程安全:通过
synchronized
同步代码块锁定红包集合,避免同一红包被重复抢夺。 - 多线程竞争:100个线程并发访问共享集合,模拟真实抢红包的随机性。
- 流程控制:通过死循环和集合判空,控制抢红包过程直至红包耗尽。
- 线程安全:通过