Java 基础(三)多线程
Java多线程核心知识点随堂笔记
前言
承接之前的面向对象、static关键字系列内容,本次笔记完整复盘多线程入门核心授课内容,从进程与线程的基础定义,到线程的创建、调度机制,再到线程安全问题的根源与synchronized互斥锁解决方案,拆解课堂所有示例代码的执行流程,补充新手易混淆的核心规则与避坑点,适配Java入门复盘与巩固需求。
一、核心基础概念:进程与线程
1. 进程的定义
进程是操作系统进行资源分配与调度的基本单位,一个正在运行的程序就是一个独立的进程,每个进程都会在内存中开辟独立的内存空间,进程之间的内存相互隔离,互不干扰。
2. 线程的定义
线程是CPU执行调度的最小单位,也叫轻量级进程。一个进程可以包含多个线程,同一个进程内的所有线程共享进程的内存资源,同时每个线程拥有自己独立的栈空间,具备独立的执行路径,互不干扰。
3. 程序的执行本质
无论是单线程还是多线程,代码的执行本质都是方法的入栈与出栈:
- 单线程场景:整个程序只有一个主线程,main方法入栈后按顺序调用其他方法,被调用的方法依次入栈,执行完毕后依次出栈,所有方法执行完成后main方法出栈,程序结束。
- 多线程场景:每个线程都有自己独立的栈空间,每个线程的方法入栈出栈互不影响,CPU通过时间片轮转的方式,交替执行不同线程的栈内指令。
课堂示例的单线程执行流程拆解:
public class Hello{
public static void main(String[] args){
Hello hello = new Hello();
hello.run(); // run方法入栈,执行完毕出栈
jump(); // jump方法入栈
}
public void run(){
// 执行逻辑,执行完毕出栈
}
public static void jump(){
flay(); // flay方法入栈
}
public static void flay(){
for(int i=0;i<10000;i++){
count++;
}
// flay执行完毕出栈,回到jump,jump执行完毕出栈
}
}
单线程下必须等hello.run()完全执行完毕,才会执行jump()方法;而多线程可以让多个循环逻辑并行执行。
二、多线程的创建与启动(继承Thread类方式)
课堂中讲解了最基础的线程创建方式:继承Thread类,重写run()方法,通过start()启动线程。
1. 核心实现步骤
- 自定义类,继承
java.lang.Thread类 - 重写Thread类的
run()方法,将线程要执行的业务逻辑写在run()方法中 - 创建自定义线程类的实例对象
- 调用实例对象的
start()方法启动线程(核心:不能直接调用run()方法)
2. 课堂完整代码示例(规范修正版)
package 多线程;
/**
* 自定义线程类:继承Thread类
*/
public class ThreadNew extends Thread {
// 用于区分不同线程的标记
private int h;
public ThreadNew(int h){
this.h = h;
}
// 重写run方法:线程的核心业务逻辑
@Override
public void run(){
if(h == 1){
for(int i=0;i<=1000;i++){
System.out.println("线程1:"+i);
}
}else{
for(int i=0;i<=1000;i++){
System.out.println("线程2:"+i);
}
}
}
}
package 多线程;
/**
* 线程测试类
*/
public class Test {
public static void main(String[] args) {
// 创建两个线程实例
ThreadNew t1 = new ThreadNew(1);
ThreadNew t2 = new ThreadNew(2);
// 启动线程:调用start()方法
t1.start();
t2.start();
System.out.println("主线程结束");
}
}
3. 核心执行现象与原理拆解
执行现象
控制台会先打印主线程结束,之后交替打印线程1和线程2的循环内容,而非先执行完t1、t2的循环,再打印主线程结束。
核心原理(课堂重点)
- start()方法的作用:调用
start()方法,并不是立刻执行完run()方法的全部内容,而是向操作系统的线程调度器注册该线程,将线程标记为就绪状态,等待CPU分配时间片执行。 - start()方法立即返回:start()方法调用后会立刻返回,不会阻塞主线程,主线程会继续往下执行,因此
System.out.println("主线程结束")会先执行。 - 主线程结束不代表程序结束:JVM进程退出的唯一条件是所有非守护线程(用户线程)全部执行完毕。主线程结束后,t1、t2两个子线程仍为非守护线程,会继续执行直到循环结束,整个JVM才会退出。
4. 新手必懂:start() 和 run() 的核心区别
| 操作 | 核心作用 | 执行效果 |
|---|---|---|
| thread.start() | 启动新线程,向系统注册线程,进入就绪状态 | 会创建新的独立栈空间,run()方法在新线程中执行,实现真正的多线程并行 |
| thread.run() | 普通的实例方法调用 | 不会创建新线程,run()方法在当前调用线程(如主线程)中串行执行,无多线程效果 |
三、线程的调度机制
1. CPU时间片轮转机制
课堂中提到,CPU会给每个线程分配极短的时间片(通常为几毫秒到几十毫秒),线程只能在分配的时间片内执行指令,时间片用完后,CPU会暂停当前线程,切换到下一个等待的线程执行。
正是这种高速切换,让宏观上多个线程看起来是同时执行的,微观上则是CPU在交替执行不同线程,这也是开篇count++出现数据覆盖的根本原因。
2. 线程的就绪队列
所有调用了start()方法、等待CPU分配时间片的线程,都会进入就绪队列,操作系统的线程调度器会按照优先级和调度算法,从就绪队列中选取线程分配时间片执行。
3. join()方法:等待线程执行完毕
课堂中解决count++最终结果获取问题时,用到了join()方法,其核心作用是:阻塞当前调用的线程,等待目标线程执行完毕后,当前线程才会继续往下执行。
例如主线程中调用t1.join(),主线程就会进入阻塞状态,直到t1线程的run()方法完全执行完毕,主线程才会继续执行后续代码。
四、线程安全问题:数据覆盖的根源与解决方案
这是本次课程的核心重点,承接第一节课的count++原子性问题,讲解问题根源与互斥锁解决方案。
1. 问题回顾
- 共享变量:
public int count = 0; - 线程1:循环10万次执行
count++ - 线程2:循环10万次执行
count++ - 预期结果:count=200000
- 实际结果:count远小于200000,出现数据覆盖
2. 问题根源再梳理
count++不是原子操作,会被拆分为3步CPU指令:
- 读取:从主内存读取count的当前值,加载到线程的工作内存
- 计算:在工作内存中对值执行+1操作
- 回写:将计算后的新值写回主内存
由于CPU会在任意时刻切换线程,可能出现线程A读取了count值后,还没来得及回写,CPU就切换到线程B,线程B也读取了同一个旧值,两个线程都基于旧值计算,最终回写时就会出现数据覆盖,导致最终结果不符合预期。
3. 解决方案:synchronized互斥锁(悲观锁)
课堂中讲解了用synchronized关键字实现互斥锁,解决线程安全问题。
核心原理
synchronized是Java内置的互斥锁(悲观锁),核心作用是保证同一时刻,最多只有一个线程能进入被synchronized修饰的代码/方法,从而保证被包裹操作的原子性,避免线程切换导致的数据覆盖。
悲观锁的核心思想:默认并发场景下一定会出现线程安全问题,因此提前加锁,同一时间只允许一个线程执行,保证操作的安全性,适配高并发场景。
4. 课堂完整解决方案代码(规范修正版)
package 多线程;
/**
* 共享资源类
*/
public class Person {
public int count;
// 用synchronized修饰add方法,保证方法的原子性
public synchronized void add(){
count++;
}
}
package 多线程;
/**
* 自定义线程类
*/
public class ThreadNew extends Thread {
private int h;
// 多个线程共享同一个Person对象
private Person p;
public ThreadNew(int h, Person p){
this.h = h;
this.p = p;
}
@Override
public void run(){
if(h == 1){
for(int i=0;i<100000;i++){
// 调用加锁的add方法
p.add();
}
}else{
for(int i=0;i<100000;i++){
p.add();
}
}
}
}
package 多线程;
public class Test {
public static void main(String[] args) throws InterruptedException {
// 多个线程共享同一个Person对象(锁对象唯一)
Person p = new Person();
ThreadNew t1 = new ThreadNew(1, p);
ThreadNew t2 = new ThreadNew(2, p);
// 启动两个线程
t1.start();
t2.start();
// 等待两个线程全部执行完毕,再打印count值
t1.join();
t2.join();
// 最终输出:主线程结束 200000
System.out.println("主线程结束"+" "+p.count);
}
}
5. 代码执行效果与原理
- 执行结果:最终一定会输出
主线程结束 200000,不会再出现数据丢失的问题。 - 核心原理:
synchronized修饰的实例方法,锁的是当前调用这个方法的对象(这里就是Person的实例p)。- 同一时刻,只有一个线程能拿到p对象的锁,进入add()方法执行count++,执行完毕后释放锁,其他线程才能抢到锁进入方法。
- 这就保证了count++的3步操作,要么一次性全部执行完,要么完全不执行,中间不会被CPU切换线程,彻底解决了数据覆盖问题。
- 加入
join()方法,保证主线程会等t1、t2全部执行完毕,再打印count的最终值,不会拿到中间的未完成结果。
五、synchronized锁的核心特性:锁的是对象,不是方法
课堂中通过两个对比示例,得出了synchronized的核心结论:synchronized修饰实例方法时,锁的是当前this对象,而不是方法本身,这是理解synchronized的核心,下面拆解两个对比示例。
示例1:fun1加synchronized锁,fun2无锁
完整代码
package 多线程一;
import java.util.concurrent.TimeUnit;
public class Test {
public static void main(String[] args) {
Data data = new Data();
// 创建线程t1,执行fun1
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
data.fun1();
}
});
t1.start();
// 主线程休眠1秒,保证t1先启动
try {
TimeUnit.SECONDS.sleep(1);
} catch (Exception e) {
e.printStackTrace();
}
// 创建线程t2,执行fun2
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
data.fun2();
}
});
t2.start();
}
}
class Data{
// fun1加synchronized锁
public synchronized void fun1(){
// 休眠3秒,模拟业务执行
try {
TimeUnit.SECONDS.sleep(3);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("fun1...............");
}
// fun2无synchronized锁
public void fun2(){
System.out.println("fun2..............");
}
}
执行结果
fun2..............
fun1...............
原理拆解
- t1先启动,调用
data.fun1(),拿到了data对象的锁,进入方法后休眠3秒,此时锁并未释放。 - 主线程休眠1秒后启动t2,t2调用
data.fun2(),由于fun2没有加synchronized锁,调用它不需要获取data对象的锁,因此t2可以直接执行fun2,立刻打印fun2..............。 - 3秒后,t1休眠结束,打印
fun1...............,释放锁。
示例2:fun1和fun2都加synchronized锁
完整代码(仅修改Data类)
class Data{
// fun1加synchronized锁
public synchronized void fun1(){
try {
TimeUnit.SECONDS.sleep(3);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("fun1...............");
}
// fun2也加synchronized锁
public synchronized void fun2(){
System.out.println("fun2..............");
}
}
执行结果
fun1...............
fun2..............
原理拆解
- t1先启动,调用
data.fun1(),拿到了data对象的锁,进入休眠3秒,锁并未释放。 - t2启动后,调用
data.fun2(),由于fun2也加了synchronized锁,必须拿到data对象的锁才能执行。 - 此时data对象的锁被t1持有,t2只能进入阻塞状态,等待t1释放锁。
- 3秒后,t1执行完毕,释放data对象的锁,t2抢到锁,执行fun2,打印
fun2..............。
核心结论(课堂重点)
- synchronized修饰实例方法时,锁的是当前this对象,不是方法代码。
- 同一个对象的所有synchronized修饰的实例方法,共用同一把对象锁:只要一个线程拿到了该对象的锁,其他线程就无法访问这个对象的任何synchronized实例方法,只能等待锁释放。
- 没有加synchronized的方法,不需要获取对象锁,随时可以被调用,不受锁的影响。
六、多线程新手避坑指南
- 共享对象必须唯一:synchronized锁要生效,必须保证多个线程竞争的是同一个锁对象。如果每个线程都new了新的对象,每个对象有自己的锁,就不会产生竞争,锁会直接失效。
- 不要用run()启动线程:直接调用run()只是普通方法调用,不会创建新线程,无法实现多线程效果,必须用start()启动线程。
- 不要在锁方法中执行耗时操作:synchronized是互斥锁,持有锁的线程执行耗时操作(比如sleep、IO),会导致其他线程长期阻塞,严重降低程序性能。
- join()的调用顺序:必须先调用start()启动线程,再调用join()等待,否则join()不会生效。
七、笔记核心总结
- 基础概念:进程是操作系统资源分配的基本单位,线程是CPU调度的最小单位,一个进程可以包含多个线程,每个线程有独立的栈空间,共享进程的内存资源。
- 线程创建:继承Thread类,重写run()方法,调用start()方法启动线程;start()是注册线程,run()是业务逻辑,二者有本质区别。
- 调度机制:CPU通过时间片轮转交替执行线程,宏观并行、微观串行;JVM会在所有非守护线程执行完毕后退出。
- 线程安全:count++等非原子操作,在多线程并发下会出现数据覆盖问题,根源是线程切换导致的操作中断。
- 解决方案:synchronized互斥锁(悲观锁),保证同一时刻只有一个线程执行加锁的代码,保证操作的原子性;核心是锁对象,而非方法。
- 关键方法:join()方法可以阻塞当前线程,等待目标线程执行完毕,用于获取线程执行的最终结果。
更多推荐



所有评论(0)