Java-day04

引入:

接下来,我们将围绕Java 内存管理多线程编程两大核心主题展开了深入学习。会议通过理论讲解、代码演示与内存模型分析相结合的方式,将复杂的技术原理转化为通俗易懂的实践案例,让参会者全面掌握了 Java 虚拟机内存结构、方法执行机制以及多线程并发控制的关键技术。本文将基于会议内容,从 Java 程序执行基础、递归算法实践、线程与并发原理、多线程问题解决四个维度进行详细解析,并结合完整代码示例帮助读者实现理论到实践的落地。

一、Java 程序执行基础:从编译到内存运行

Java 作为一门 “一次编译,到处运行” 的跨平台语言,其程序执行过程依赖于 Java 虚拟机(JVM)的支撑。理解 Java 程序从编译到内存运行的完整流程,是掌握内存管理与多线程编程的前提。

1.1 Java 程序的编译与运行流程

Java 程序的执行分为编译阶段运行阶段两个核心步骤,具体流程如下:

  1. 编译阶段:开发者编写的.java源文件,通过javac编译器编译为字节码文件(.class文件)。字节码文件是一种与平台无关的二进制文件,包含了 Java 虚拟机可识别的指令集。
  2. 运行阶段:通过java命令启动 Java 虚拟机,JVM 会加载.class文件到内存中,并解析字节码指令,最终在操作系统上执行程序。

示例:假设我们编写了一个简单的HelloWorld.java文件,编译与运行命令如下:

编译:将HelloWorld.java编译为HelloWorld.class

javac HelloWorld.java

运行:启动JVM加载HelloWorld.class并执行

java HelloWorld

1.2 Java 虚拟机内存结构

Java 虚拟机在运行时会将内存划分为多个不同的区域,每个区域承担特定的功能。会议中重点讲解了栈内存堆内存方法区三大核心区域,其职责与特点如下:

内存区域

核心职责

数据存储特点

栈内存(虚拟机栈)

存储方法执行过程中的局部变量、操作数栈、方法出口等信息

线程私有,每个方法调用会创建一个 “栈帧”,方法执行完毕后栈帧出栈,内存自动释放(栈溢出风险)

堆内存

存储 Java 对象实例(包括数组)

线程共享,所有对象的内存分配与回收均在堆中进行,由垃圾回收器(GC)负责内存回收(内存泄漏风险)

方法区

存储类的元数据信息(如类名、字段、方法定义、常量池等)

线程共享,在 JDK 8 及以后,方法区的实现为 “元空间”(Metaspace),取代了之前的 “永久代”

1.3 方法执行的栈帧机制

Java 程序的执行本质是方法的不断入栈与出栈。当一个方法被调用时,JVM 会在栈内存中为该方法创建一个栈帧(Stack Frame),栈帧包含以下核心信息:

  • 局部变量表:存储方法中的局部变量(如基本数据类型、对象引用);
  • 操作数栈:用于方法执行过程中的临时数据运算(如加法、赋值操作);
  • 方法出口:记录方法执行完毕后返回的地址(即调用该方法的下一条指令位置)。

方法执行流程示例:以add(1,2)方法调用为例,栈帧的变化过程如下:

  1. 调用add(1,2)时,JVM 在栈内存中创建add方法的栈帧,将参数 1、2 存入局部变量表;
  1. 执行return a + b时,将局部变量表中的 1 和 2 压入操作数栈,计算求和后将结果返回;
  1. 方法执行完毕,add方法的栈帧从栈内存中弹出,栈指针回到调用该方法的位置,继续执行后续代码。

代码示例:通过简单的加法方法演示栈帧机制:

public class StackFrameDemo {

    // 加法方法

    public static int add(int a, int b) {

        // 局部变量c存储a+b的结果

        int c = a + b;

        return c;

    }

    public static void main(String[] args) {

        // 调用add方法,触发栈帧创建

        int result = add(1, 2);

        System.out.println("计算结果:" + result); // 输出:计算结果:3

    }

}

内存分析:main方法是程序的入口,JVM 首先为main方法创建栈帧;当执行add(1,2)时,add方法的栈帧入栈,执行完毕后出栈,最终main方法的栈帧继续执行打印逻辑。

二、递归算法与斐波那契数列:栈内存的实战应用

递归算法是 Java 中常用的编程思想,其核心是 “方法调用自身”,而递归的执行过程直接依赖于栈内存的栈帧管理。会议中通过斐波那契数列的实现,深入讲解了递归算法与栈内存的关联,以及递归可能引发的栈溢出问题。

2.1 递归算法的核心原理

递归算法的执行需满足两个条件:

  1. 基线条件:递归的终止条件(即不再调用自身的条件),若缺少基线条件会导致无限递归;
  2. 递归条件:方法调用自身的逻辑,且每次调用需逐步靠近基线条件。

递归的执行过程可概括为 “递推” 与 “回归” 两个阶段:

  • 递推阶段:方法不断调用自身,每次调用都会在栈内存中创建新的栈帧,直到达到基线条件;
  • 回归阶段:从基线条件开始,栈帧逐层出栈,将结果逐步返回给上一层调用,最终得到最终结果。

2.2 斐波那契数列的递归实现

斐波那契数列的定义为:

  • F (0) = 0(基线条件 1);
  • F (1) = 1(基线条件 2);
  • F (n) = F (n-1) + F (n-2)(递归条件,n ≥ 2)。

代码示例:递归实现斐波那契数列,并打印前 10 项:

public class FibonacciRecursion {

    /**

     * 递归计算斐波那契数列的第n项

     * @param n 数列的索引(从0开始)

     * @return 第n项的值

     */

    public static long fibonacci(int n) {

        // 基线条件:终止递归

        if (n == 0) {

            return 0;

        } else if (n == 1) {

            return 1;

        }

        // 递归条件:调用自身,逐步靠近基线条件

        return fibonacci(n - 1) + fibonacci(n - 2);

    }

    public static void main(String[] args) {

        System.out.println("斐波那契数列前10项:");

        for (int i = 0; i < 10; i++) {

            System.out.print(fibonacci(i) + " ");

        }

        // 输出:斐波那契数列前10项:0 1 1 2 3 5 8 13 21 34

    }

}

2.3 递归与栈内存的关联分析

以计算fibonacci(3)为例,栈内存的栈帧变化过程如下:

  1. 调用fibonacci(3),创建栈帧 1,因 n≠0 且 n≠1,需调用fibonacci(2)和fibonacci(1);
  2. 调用fibonacci(2),创建栈帧 2,因 n≠0 且 n≠1,需调用fibonacci(1)和fibonacci(0);
  3. 调用fibonacci(1),创建栈帧 3,满足基线条件,返回 1,栈帧 3 出栈;
  4. 调用fibonacci(0),创建栈帧 4,满足基线条件,返回 0,栈帧 4 出栈;
  5. fibonacci(2)计算 1+0=1,返回 1,栈帧 2 出栈;
  6. 调用fibonacci(1),创建栈帧 5,返回 1,栈帧 5 出栈;
  7. fibonacci(3)计算 1+1=2,返回 2,栈帧 1 出栈,递归结束。

注意事项:递归调用会不断创建栈帧,若递归深度过大(如计算fibonacci(1000)),会导致栈内存溢出,抛出StackOverflowError。因此,对于深度较大的场景,建议使用迭代或动态规划替代递归。

三、线程与并发:Java 多线程的核心原理

在 Java 程序中,线程是程序执行的最小单位。多线程技术可以充分利用 CPU 资源,实现程序的并发执行,但同时也会引入线程安全问题。会议中详细讲解了线程的本质、调度机制以及并发控制的核心思想。

3.1 线程的本质:程序执行的路径

  • 进程:操作系统中资源分配的基本单位(如一个 Java 程序运行时就是一个进程),每个进程拥有独立的内存空间;
  • 线程:进程内的执行单元(一个进程可包含多个线程),线程共享进程的内存空间(如堆、方法区),但拥有独立的栈内存(虚拟机栈)。

线程的核心特点

  1. 线程私有:栈内存、程序计数器(记录线程执行的指令位置);
  2. 线程共享:堆内存、方法区、系统资源(如文件句柄)。

为什么需要多线程

  • 提升 CPU 利用率:当一个线程因 IO 操作(如读取文件、网络请求)阻塞时,其他线程可继续执行,避免 CPU 空闲;
  • 提升程序响应速度:如 GUI 程序中,后台线程处理数据,主线程更新界面,避免界面卡顿。

3.2 线程调度:时间片轮转机制

操作系统通过线程调度器管理线程的执行,Java 中线程的调度依赖于操作系统的底层支持,主要采用 “时间片轮转” 策略:

  • 时间片:操作系统为每个线程分配的一段执行时间(如 10ms);
  • 执行过程:线程获取时间片后执行,时间片结束后线程被暂停,释放 CPU 资源,调度器再为其他线程分配时间片;
  • 宏观与微观:宏观上多个线程 “同时执行”(并发),微观上线程交替执行(串行)。

Java 中线程的状态

根据《Java 虚拟机规范》,线程的生命周期包含 6 种状态,状态转换如下:

  1. 新建(NEW):创建线程对象但未调用start()方法;
  2. 运行(RUNNABLE):调用start()后,线程处于可执行状态(包含正在执行和等待 CPU 时间片两种情况);
  3. 阻塞(BLOCKED):线程等待锁资源(如进入synchronized块但未获取锁);
  4. 等待(WAITING):线程无期限等待其他线程唤醒(如调用Object.wait()且未指定超时时间);
  5. 超时等待(TIMED_WAITING):线程在指定时间内等待(如调用Thread.sleep(1000));
  6. 终止(TERMINATED):线程执行完毕或因异常退出。

3.3 并发控制:解决共享资源竞争问题

多线程共享资源时,若多个线程同时修改共享变量,会导致 “数据不一致” 问题,这就是 “线程安全” 问题的根源。

问题示例:两个线程同时对一个计数器进行递增操作,预期结果为 200 万,但实际结果可能小于 200 万。

/**

 * 线程安全问题演示:多线程共享计数器

 */

public class ThreadSafetyDemo {

    // 共享变量:计数器

    private static int count = 0;

    // 递增方法:对count进行+1操作

    public static void increment() {

        count++; // 看似简单的一行代码,实际包含3个步骤:读取count、+1、写入count

    }

    public static void main(String[] args) throws InterruptedException {

        // 创建线程1:执行100万次递增

        Thread thread1 = new Thread(() -> {

            for (int i = 0; i < 1000000; i++) {

                increment();

            }

        });

        // 创建线程2:执行100万次递增

        Thread thread2 = new Thread(() -> {

            for (int i = 0; i < 1000000; i++) {

                increment();

            }

        });

        // 启动线程

        thread1.start();

        thread2.start();

        // 等待两个线程执行完毕(后续讲解join方法)

        thread1.join();

        thread2.join();

        // 打印最终结果

        System.out.println("最终计数器值:" + count); // 预期2000000,实际可能为1800000等

    }

}

问题原因:count++操作并非原子操作,而是分为 “读取 - 修改 - 写入” 三个步骤。当线程 1 读取count=100后,时间片到期被暂停;此时线程 2 读取count=100并完成 + 1 写入,count=101;线程 1 恢复后继续 + 1 写入,count=101(而非预期的 102)。

四、多线程实践与问题解决:从线程安全到原子性保障

针对多线程共享资源的安全问题,会议中介绍了多种解决方案,包括join方法的线程同步、synchronized关键字的加锁机制以及Atomic原子类的使用。本节将结合代码示例,详细讲解这些方案的原理与实践。

4.1 使用 join 方法:等待子线程执行完毕

Thread.join()方法的作用是:让主线程等待子线程执行完毕后再继续执行,避免主线程提前打印结果导致数据不准确。但需要注意的是,join仅能保证线程的执行顺序,无法解决共享资源的竞争问题。

代码示例:在上述ThreadSafetyDemo中,thread1.join()和thread2.join()的作用是让主线程等待两个子线程执行完 100 万次递增后,再打印count的值。若不使用join,主线程可能在子线程执行过程中就打印count,导致结果为 0 或其他中间值。

join 方法的本质:调用join后,主线程会进入WAITING状态,直到子线程执行完毕(状态变为TERMINATED),主线程才会被唤醒并继续执行。

4.2 使用 synchronized 关键字:保证方法的原子性

synchronized关键字是 Java 中最常用的同步锁机制,其核心作用是:保证同一时间只有一个线程能执行被synchronized修饰的方法或代码块,从而避免共享资源的竞争。

synchronized的实现原理基于 “对象监视器锁(Monitor Lock)”:

  • 当线程进入synchronized方法时,会尝试获取该方法所属对象的监视器锁;
  • 若锁未被占用,线程获取锁并执行方法;
  • 若锁已被其他线程占用,当前线程会进入BLOCKED状态,直到锁被释放后重新尝试获取。

代码修复:使用synchronized修饰increment方法,保证count++操作的原子性:

public class SynchronizedDemo {

    private static int count = 0;

    // 使用synchronized修饰方法,保证原子性

    public static synchronized void increment() {

        count++; // 此时,同一时间只有一个线程能执行count++

    }

    public static void main(String[] args) throws InterruptedException {

        Thread thread1 = new Thread(() -> {

            for (int i = 0; i < 1000000; i++) {

                increment();

            }

        });

        Thread thread2 = new Thread(() -> {

            for (int i = 0; i < 1000000; i++) {

                increment();

            }

        });

        thread1.start();

        thread2.start();

        thread1.join();

        thread2.join();

        System.out.println("最终计数器值:" + count); // 输出:2000000(正确)

    }

}

synchronized 的两种用法

  1. 修饰普通方法:锁对象为当前实例对象(this);
  2. 修饰静态方法:锁对象为当前类的 Class 对象(如SynchronizedDemo.class);
  3. 修饰代码块:锁对象可自定义(如 synchronized (lockObj) { ... })。

4.3 使用 Atomic 原子类:更高效的原子操作

synchronized虽然能保证原子性,但属于 “重量级锁”,在高并发场景下可能存在性能瓶颈。其核心问题在于:当线程竞争激烈时,未获取锁的线程会进入BLOCKED状态,频繁的线程上下文切换会消耗大量 CPU 资源。而 Java 并发包(java.util.concurrent.atomic)提供的Atomic 原子类,通过底层的 “无锁编程” 思想(基于 CPU 的 CAS 指令),实现了更轻量级、更高性能的原子操作。

4.3.1 Atomic 原子类的底层原理:CAS 指令

CAS(Compare and Swap,比较并交换)是 CPU 提供的一条原子指令,其核心逻辑可概括为:

  • 参数:内存地址 V、预期值 A、新值 B;
  • 执行过程:若内存地址 V 中的值等于预期值 A,则将其更新为新值 B,返回true;否则不做修改,返回false;
  • 原子性:CAS 指令由 CPU 硬件保证原子性,无需通过锁机制实现线程同步。

以AtomicInteger的incrementAndGet()方法(自增并返回新值)为例,其底层实现伪代码如下:


public final int incrementAndGet() {

while (true) {

// 1. 获取当前内存中的值(预期值A)

int current = get();

// 2. 计算新值B(current + 1)

int next = current + 1;

// 3. 执行CAS操作:若当前值仍为current,则更新为next

if (compareAndSet(current, next)) {

return next;

}

// 4. 若CAS失败(值被其他线程修改),则循环重试

}

}

这种 “循环重试” 的机制被称为 “自旋”,相比synchronized的 “线程阻塞”,自旋不会导致线程上下文切换,在低至中并发场景下性能更优。

4.3.2 Atomic 原子类的常用类型与实践

Java 提供了多种 Atomic 原子类,覆盖基本数据类型、引用类型和数组类型,常用类型如下:

原子类

核心功能

适用场景

AtomicInteger

整数的原子自增、自减、赋值等操作

计数器、序号生成器

AtomicLong

长整数的原子操作

时间戳记录、大数值计数器

AtomicBoolean

布尔值的原子修改

状态标记(如 “是否初始化完成”)

AtomicReference

引用类型的原子修改

原子更新对象引用

代码示例:使用AtomicInteger解决计数器线程安全问题


import java.util.concurrent.atomic.AtomicInteger;

public class AtomicDemo {

// 使用AtomicInteger替代普通int,保证原子操作

private static AtomicInteger count = new AtomicInteger(0);

// 自增方法:直接调用AtomicInteger的原子方法

public static void increment() {

count.incrementAndGet(); // 原子自增(相当于count++,但线程安全)

}

public static void main(String[] args) throws InterruptedException {

// 创建两个线程,各执行100万次自增

Thread thread1 = new Thread(() -> {

for (int i = 0; i < 1000000; i++) {

increment();

}

});

Thread thread2 = new Thread(() -> {

for (int i = 0; i < 1000000; i++) {

increment();

}

});

thread1.start();

thread2.start();

thread1.join();

thread2.join();

// 打印结果:必然为2000000(线程安全)

System.out.println("最终计数器值:" + count.get());

}

}

执行结果分析:AtomicInteger的incrementAndGet()方法通过 CAS 指令保证自增操作的原子性,即使两个线程同时执行,也不会出现 “数据覆盖” 问题,最终结果始终为 200 万。

4.3.3 Atomic 原子类与 synchronized 的性能对比

为了直观展示两者的性能差异,我们通过 “10 个线程各执行 100 万次自增” 的场景进行测试,对比执行时间(单位:毫秒):

并发控制方式

10 线程 ×100 万次自增执行时间

核心优势

核心劣势

synchronized

约 80-120ms

支持复合操作(如多步逻辑)

线程阻塞,高并发下性能差

AtomicInteger

约 10-30ms

无锁自旋,性能高

仅支持单一原子操作

结论

  • 单一原子操作(如自增、赋值)场景下,Atomic原子类的性能远优于synchronized;
  • 复合操作(如 “先判断后修改”“多变量原子更新”)场景下,Atomic原子类无法保证原子性,需使用synchronized或Lock锁。

4.3.4 Atomic 原子类的注意事项

  •  ABA 问题
  • 问题描述:若线程 1 读取值为 A,线程 2 将 A 修改为 B,再修改回 A,线程 1 执行 CAS 时会认为值未变,从而成功更新,但实际值已被修改过;
  • 解决方案:使用AtomicStampedReference(带版本号的原子引用),通过 “值 + 版本号” 双重判断避免 ABA 问题。
  • 自旋过度问题
  • 问题描述:在极高并发场景下,CAS 重试次数过多会导致 CPU 占用率飙升;
  • 解决方案:极高并发场景下,可结合LongAdder(JDK 8+)替代AtomicLong,LongAdder通过 “分段锁” 思想减少自旋竞争。
  • 仅支持单一原子操作
  • Atomic原子类仅能保证自身方法的原子性,若需多个操作的原子性(如 “自增后判断是否超过阈值”),需额外结合锁机制。

4.3.5 扩展实践:使用 AtomicStampedReference 解决 ABA 问题


import java.util.concurrent.atomic.AtomicStampedReference;

public class ABADemo {

public static void main(String[] args) {

// 初始化:值为100,版本号为1

AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(100, 1);

// 线程1:尝试将100修改为200(预期版本号1)

new Thread(() -> {

int stamp = ref.getStamp(); // 获取当前版本号1

System.out.println("线程1:当前版本号=" + stamp);

try {

Thread.sleep(1000); // 休眠,让线程2先执行

} catch (InterruptedException e) {

e.printStackTrace();

}

// CAS操作:值从100→200,版本号从1→2

boolean success = ref.compareAndSet(100, 200, stamp, stamp + 1);

System.out.println("线程1:CAS是否成功=" + success + ",当前值=" + ref.getReference());

}).start();

// 线程2:先将100→200,再将200→100(制造ABA问题)

new Thread(() -> {

int stamp = ref.getStamp(); // 获取当前版本号1

System.out.println("线程2:当前版本号=" + stamp);

// 第一次CAS:100→200,版本号1→2

ref.compareAndSet(100, 200, stamp, stamp + 1);

System.out.println("线程2:第一次修改后,值=" + ref.getReference() + ",版本号=" + ref.getStamp());

// 第二次CAS:200→100,版本号2→3

stamp = ref.getStamp(); // 获取新版本号2

ref.compareAndSet(200, 100, stamp, stamp + 1);

System.out.println("线程2:第二次修改后,值=" + ref.getReference() + ",版本号=" + ref.getStamp());

}).start();

}

}

执行结果

线程1:当前版本号=1

线程2:当前版本号=1

线程2:第一次修改后,值=200,版本号=2

线程2:第二次修改后,值=100,版本号=3

线程1:CAS是否成功=false,当前值=100

分析:线程 1 的 CAS 操作因 “版本号不匹配”(预期版本 1,实际版本 3)失败,成功避免了 ABA 问题。

五、总结

5.1多线程实践与问题解决核心内容

该部分围绕多线程共享资源安全问题展开,重点讲解线程同步方法与原子性保障方案,核心内容如下:

5.1.1 线程同步基础工具:join 方法

  • 功能定位:解决主线程与子线程执行顺序问题,让主线程等待子线程执行完毕后再继续执行,避免主线程提前获取未完成计算的共享数据。
  • 局限性:仅保证线程执行顺序,无法解决共享资源竞争导致的数据不一致问题,例如多线程对计数器递增时,仍可能出现结果小于预期值的情况。
  • 实践场景:在多线程计算场景中,需等待所有子线程完成任务后汇总结果时使用,如统计多个线程处理数据的总条数。

5.1.2 重量级锁方案:synchronized 关键字

  • 核心原理:基于 “对象监视器锁(Monitor Lock)” 实现,保证同一时间仅一个线程能执行被修饰的方法或代码块,从而确保操作原子性。
  • 使用方式:可修饰普通方法(锁对象为当前实例)、静态方法(锁对象为类的 Class 对象)、代码块(锁对象自定义)。
  • 实践效果:修复多线程计数器问题,例如两个线程各执行 100 万次递增操作,使用 synchronized 修饰递增方法后,最终结果能准确达到 200 万,解决数据不一致问题。
  • 性能特点:属于重量级锁,高并发场景下,未获取锁的线程会进入 BLOCKED 状态,频繁上下文切换可能导致性能瓶颈。

5.2Atomic 原子类核心内容

该部分聚焦轻量级原子操作方案,详解 Atomic 原子类的原理、实践、性能及注意事项,核心内容如下:

5.2.1 底层实现原理:CAS 指令

  • 指令定义:CPU 提供的 Compare and Swap(比较并交换)原子指令,需传入内存地址 V、预期值 A、新值 B。
  • 执行逻辑:若内存地址 V 中的值等于预期值 A,則将其更新为 B 并返回 true;否则不修改并返回 false,由硬件保证操作原子性。
  • 自旋机制:以 AtomicInteger 的 incrementAndGet () 方法为例,通过循环重试 CAS 操作,直至成功,避免线程阻塞。

5.2.2 常用类型与实践应用

  • 核心类型:涵盖 AtomicInteger(整数原子操作)、AtomicLong(长整数原子操作)、AtomicBoolean(布尔值原子操作)、AtomicStampedReference(带版本号的原子引用)等,满足不同数据类型的原子需求。
  • 实践案例:使用 AtomicInteger 解决计数器问题,调用其 incrementAndGet () 方法实现原子递增,无需加锁即可保证线程安全,两个线程各执行 100 万次递增,最终结果准确为 200 万。

5.2.3 性能对比与适用场景

  • 性能优势:与 synchronized 相比,在单一原子操作场景下性能更优。例如 10 个线程各执行 100 万次自增,AtomicInteger 执行时间约 10-30ms,synchronized 约 80-120ms,因无锁自旋避免上下文切换损耗。
  • 适用场景:适用于单一原子操作(如计数器、序号生成);不适用于复合操作(如 “判断 + 修改” 多步逻辑),此类场景需结合锁机制。

5.2.4 关键注意事项

  • ABA 问题:线程 1 读取值 A 后,线程 2 将 A 改为 B 再改回 A,线程 1CAS 时误判值未变。解决方案是使用 AtomicStampedReference,通过 “值 + 版本号” 双重判断,例如线程 2 修改值后版本号递增,线程 1 因版本号不匹配 CAS 失败,避免 ABA 问题。
  • 自旋过度问题:极高并发下,CAS 重试次数过多导致 CPU 占用率飙升,可使用 JDK8 + 的 LongAdder,通过 “分段锁” 减少竞争,优化性能。
  • 功能局限:仅保证自身方法原子性,多操作原子性需额外结合锁。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐