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. 核心实现步骤

  1. 自定义类,继承java.lang.Thread
  2. 重写Thread类的run()方法,将线程要执行的业务逻辑写在run()方法中
  3. 创建自定义线程类的实例对象
  4. 调用实例对象的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的循环,再打印主线程结束。

核心原理(课堂重点)
  1. start()方法的作用:调用start()方法,并不是立刻执行完run()方法的全部内容,而是向操作系统的线程调度器注册该线程,将线程标记为就绪状态,等待CPU分配时间片执行。
  2. start()方法立即返回:start()方法调用后会立刻返回,不会阻塞主线程,主线程会继续往下执行,因此System.out.println("主线程结束")会先执行。
  3. 主线程结束不代表程序结束: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指令:

  1. 读取:从主内存读取count的当前值,加载到线程的工作内存
  2. 计算:在工作内存中对值执行+1操作
  3. 回写:将计算后的新值写回主内存

由于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,不会再出现数据丢失的问题。
  • 核心原理:
    1. synchronized修饰的实例方法,锁的是当前调用这个方法的对象(这里就是Person的实例p)。
    2. 同一时刻,只有一个线程能拿到p对象的锁,进入add()方法执行count++,执行完毕后释放锁,其他线程才能抢到锁进入方法。
    3. 这就保证了count++的3步操作,要么一次性全部执行完,要么完全不执行,中间不会被CPU切换线程,彻底解决了数据覆盖问题。
    4. 加入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...............
原理拆解
  1. t1先启动,调用data.fun1(),拿到了data对象的锁,进入方法后休眠3秒,此时锁并未释放。
  2. 主线程休眠1秒后启动t2,t2调用data.fun2(),由于fun2没有加synchronized锁,调用它不需要获取data对象的锁,因此t2可以直接执行fun2,立刻打印fun2..............
  3. 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..............
原理拆解
  1. t1先启动,调用data.fun1(),拿到了data对象的锁,进入休眠3秒,锁并未释放。
  2. t2启动后,调用data.fun2(),由于fun2也加了synchronized锁,必须拿到data对象的锁才能执行
  3. 此时data对象的锁被t1持有,t2只能进入阻塞状态,等待t1释放锁。
  4. 3秒后,t1执行完毕,释放data对象的锁,t2抢到锁,执行fun2,打印fun2..............

核心结论(课堂重点)

  1. synchronized修饰实例方法时,锁的是当前this对象,不是方法代码
  2. 同一个对象的所有synchronized修饰的实例方法,共用同一把对象锁:只要一个线程拿到了该对象的锁,其他线程就无法访问这个对象的任何synchronized实例方法,只能等待锁释放。
  3. 没有加synchronized的方法,不需要获取对象锁,随时可以被调用,不受锁的影响。

六、多线程新手避坑指南

  1. 共享对象必须唯一:synchronized锁要生效,必须保证多个线程竞争的是同一个锁对象。如果每个线程都new了新的对象,每个对象有自己的锁,就不会产生竞争,锁会直接失效。
  2. 不要用run()启动线程:直接调用run()只是普通方法调用,不会创建新线程,无法实现多线程效果,必须用start()启动线程。
  3. 不要在锁方法中执行耗时操作:synchronized是互斥锁,持有锁的线程执行耗时操作(比如sleep、IO),会导致其他线程长期阻塞,严重降低程序性能。
  4. join()的调用顺序:必须先调用start()启动线程,再调用join()等待,否则join()不会生效。

七、笔记核心总结

  1. 基础概念:进程是操作系统资源分配的基本单位,线程是CPU调度的最小单位,一个进程可以包含多个线程,每个线程有独立的栈空间,共享进程的内存资源。
  2. 线程创建:继承Thread类,重写run()方法,调用start()方法启动线程;start()是注册线程,run()是业务逻辑,二者有本质区别。
  3. 调度机制:CPU通过时间片轮转交替执行线程,宏观并行、微观串行;JVM会在所有非守护线程执行完毕后退出。
  4. 线程安全:count++等非原子操作,在多线程并发下会出现数据覆盖问题,根源是线程切换导致的操作中断。
  5. 解决方案:synchronized互斥锁(悲观锁),保证同一时刻只有一个线程执行加锁的代码,保证操作的原子性;核心是锁对象,而非方法。
  6. 关键方法:join()方法可以阻塞当前线程,等待目标线程执行完毕,用于获取线程执行的最终结果。
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐