Java内存模型与多线程并发编程培训
开篇:为什么面试总问JVM内存模型?
面试Java岗位时,“JVM内存模型”“进程与线程的区别”"线程安全怎么保证"几乎是必问题。
这些问题考的不是背诵能力,而是你能不能讲清楚:一段Java代码从写出来到跑起来,中间经历了什么?内存里到底发生了什么?多线程为什么会把数据改坏?
本次培训就围绕这三个问题展开。
一、Java程序执行流程
1.1 一段代码是怎么跑起来的
写好的.java文件只是文本,计算机读不懂。必须先编译成.class字节码,再加载到JVM中运行。
整个过程就像翻译一本书:先由javac把中文(源代码)翻译成中间语言(字节码),再由java把中间语言交给本地解释器执行。
1.2 javac与java的分工
# 第一步:编译javac HelloWorld.java# 第二步:运行javaHelloWorldjavac负责把源代码编译成字节码文件。java负责启动JVM、加载类、找到main方法并执行。
1.3 字节码里有什么
.class文件里存储的是字节码指令和数据。指令告诉JVM做什么,数据是操作对象。
比如int a = 10,字节码里会有一条"把10压入操作数栈"的指令,以及变量a在局部变量表中的位置信息。
1.4 程序入口为什么是main方法
JVM加载完类后,会主动搜索public static void main(String[] args)方法。这个方法固定作为程序入口。
为什么是static?因为调用main时还没有创建对象,必须不依赖实例就能执行。
1.5 完整执行流程图
HelloWorld.java ↓ javac编译 ↓ HelloWorld.class ↓ java命令启动JVM ↓ JVM加载字节码 ↓ 找到main方法 ↓ 创建main线程 ↓ 从main方法开始执行二、CPU执行原理
2.1 CPU的两大核心部件
CPU主要由运算器和控制器组成。
运算器负责算术运算、逻辑运算、移位运算。控制器负责取指令、解析指令、指挥运算器工作。
2.2 程序执行的本质循环
CPU执行程序时,不断重复三个步骤:
- 取指令:从内存中读取下一条指令
- 译码:解析这条指令要做什么
- 执行:运算器完成具体计算
这个循环非常快,现代CPU每秒可以执行几十亿次。
2.3 IO操作与系统调用
当程序调用System.out.println或读写文件时,CPU会发起系统调用,交给操作系统内核完成IO操作。
IO操作通常比CPU计算慢得多。单线程程序在等IO时,CPU就空闲了。多线程可以在这个空档期让CPU去执行其他任务。
三、JVM内存模型详解
3.1 进程与JVM的关系
操作系统为每个进程分配独立的内存空间。一个Java程序启动后,操作系统会创建一个进程。
JVM运行在这个进程内部,再把进程内存划分成自己的几个区域。我们常说的"JVM内存模型",指的就是JVM内部的内存划分。
3.2 方法区:类的蓝图档案室
方法区存储类元数据,包括类的结构、常量池、静态变量、方法字节码等。
可以把方法区理解成建筑图纸档案室。程序运行时,JVM需要在这里找到类的定义,才知道怎么创建对象、怎么执行方法。
【面试高频】JDK8之前方法区叫永久代(PermGen),JDK8之后改为元空间(Metaspace),使用本地内存。
3.3 堆:对象的仓库
堆是JVM中最大的一块内存区域,所有通过new创建的对象实例都存放在这里。
堆是所有线程共享的。多个线程可以同时访问堆中的同一个对象,这也是线程安全问题产生的根源。
3.4 虚拟机栈:方法调用的舞台
虚拟机栈是控制程序执行流程的关键。每个线程都有自己独立的虚拟机栈。
方法调用时,JVM会创建一个栈帧压入栈顶。栈帧里保存局部变量表、操作数栈、动态链接、方法返回地址等信息。
方法执行结束,栈帧出栈,程序回到调用位置继续执行。栈采用后进先出结构。
3.5 程序计数器:线程的指南针
程序计数器记录当前线程正在执行的字节码指令地址。每个线程独立拥有一个程序计数器。
线程切换时,程序计数器保证线程恢复执行时能从上次中断的位置继续。
3.6 本地方法栈
本地方法栈与虚拟机栈类似,但它是为执行Native方法服务的。比如Thread.start()底层就调用了本地方法。
3.7 直接内存
直接内存不是JVM运行时数据区的一部分,但被NIO频繁使用。它绕过JVM堆,直接在操作系统内存中分配,可以提高IO效率。
3.8 JVM内存区域对比表
| 区域 | 存储内容 | 线程共享 | 典型异常 |
|---|---|---|---|
| 方法区 | 类信息、常量、静态变量 | 共享 | Metaspace OOM |
| 堆 | 对象实例 | 共享 | OutOfMemoryError |
| 虚拟机栈 | 栈帧、局部变量、返回地址 | 私有 | StackOverflowError |
| 程序计数器 | 当前指令地址 | 私有 | 无 |
| 本地方法栈 | Native方法信息 | 私有 | StackOverflowError |
3.9 线程共享 vs 线程私有
| 线程共享 | 线程私有 |
|---|---|
| 方法区 | 虚拟机栈 |
| 堆 | 程序计数器 |
| 本地方法栈 |
线程私有的区域不会互相干扰。线程共享的区域才需要考虑并发安全问题。
四、虚拟机栈与方法调用
4.1 栈帧结构
每个方法调用对应一个栈帧,栈帧包含四部分:
- 局部变量表:存放方法参数和局部变量
- 操作数栈:存放运算过程中的中间结果
- 动态链接:指向运行时常量池的方法引用
- 方法返回地址:方法执行完后回到哪里
4.2 方法入栈出栈过程
publicclassStackDemo{publicstaticvoidmain(String[]args){methodA();}publicstaticvoidmethodA(){methodB();}publicstaticvoidmethodB(){System.out.println("执行B");}}执行流程的栈变化:
main 入栈 ↓ 调用 methodA methodA 入栈 ↓ 调用 methodB methodB 入栈 ↓ 执行 println println 入栈 ↓ println 执行完毕 println 出栈 ↓ methodB 执行完毕 methodB 出栈 ↓ methodA 执行完毕 methodA 出栈 ↓ main 执行完毕 main 出栈4.3 递归执行案例
以斐波那契数列为例:
publicintfib(intn){if(n==1||n==2){return1;}returnfib(n-1)+fib(n-2);}调用fib(5)的完整调用树:
fib(5) ├── fib(4) │ ├── fib(3) │ │ ├── fib(2) = 1 │ │ └── fib(1) = 1 │ └── fib(2) = 1 └── fib(3) ├── fib(2) = 1 └── fib(1) = 1每一层调用都会创建新栈帧入栈,遇到终止条件后返回值逐层出栈累加,最终得到5。
4.4 栈溢出的原因
如果递归没有终止条件,或者递归层次太深,栈帧会不断入栈直到超过栈容量,抛出:
java.lang.StackOverflowError// 错误示例:没有终止条件publicvoidbadRecursion(){badRecursion();// 无限递归}五、进程与线程
5.1 什么是进程
进程是操作系统进行资源分配和调度的基本单位。一个运行中的Java程序就是一个进程。
进程拥有独立的内存空间、文件描述符、句柄等资源。进程之间资源隔离,一个进程崩溃通常不会影响另一个进程。
5.2 什么是线程
线程是程序执行过程的最小单位,是CPU调度和执行的基本单位。一个进程可以包含多个线程。
线程共享进程的资源,包括内存空间、打开的文件等。但每个线程有自己独立的虚拟机栈和程序计数器。
5.3 进程与线程的区别
| 对比项 | 进程 | 线程 |
|---|---|---|
| 资源分配 | 是资源分配的基本单位 | 不是 |
| CPU调度 | 不是CPU调度单位 | 是CPU调度单位 |
| 内存空间 | 独立 | 共享进程内存 |
| 切换开销 | 大(需要切换页表) | 小(只需切换栈和寄存器) |
| 通信方式 | IPC、管道、Socket | 共享内存,需同步机制 |
| 崩溃影响 | 通常不影响其他进程 | 可能导致整个进程崩溃 |
5.4 为什么Java更关注线程
Java程序本身是一个进程,但开发中我们更关心线程。因为线程更轻量,能更细粒度地控制程序执行流程。
多线程可以充分利用多核CPU,提高程序吞吐量和响应速度。
5.5 Java创建线程的三种方式
// 方式一:继承 Thread 类classMyThreadextendsThread{@Overridepublicvoidrun(){System.out.println("Thread running");}}// 方式二:实现 Runnable 接口classMyRunnableimplementsRunnable{@Overridepublicvoidrun(){System.out.println("Runnable running");}}// 方式三:实现 Callable 接口(有返回值)classMyCallableimplementsCallable<String>{@OverridepublicStringcall(){return"Callable result";}}【面试高频】推荐使用实现
Runnable或Callable的方式,因为Java是单继承,继承Thread会限制扩展性。
六、多线程与并发
6.1 CPU调度:时间片轮转
CPU在同一时刻只能执行一个任务。操作系统采用时间片轮转算法,让每个线程轮流执行一小段时间。
时间片通常只有几十毫秒。线程在时间片用完后被挂起,CPU切换到另一个线程执行。
6.2 并发与并行的区别
| 概念 | 定义 | 硬件要求 |
|---|---|---|
| 并发 | 宏观上同时,微观上交替 | 单核CPU即可 |
| 并行 | 真正同时执行 | 需要多核CPU |
单核CPU只能实现并发,多核CPU才能同时运行多个线程,实现并行。
6.3 多线程的优势与代价
优势:
- 提高CPU利用率
- 提升程序响应速度
- 充分利用多核CPU
代价:
- 线程创建和切换有开销
- 需要处理线程安全问题
- 增加程序复杂度和调试难度
6.4 上下文切换
线程切换时,CPU需要保存当前线程的执行状态(寄存器、程序计数器等),再加载下一个线程的状态。这个过程叫上下文切换。
频繁的上下文切换会消耗CPU资源。所以线程数量不是越多越好。
七、线程安全问题
7.1 线程安全三条件
多个线程访问共享资源时,只要同时满足以下三个条件,就可能出现线程安全问题:
- 多线程环境
- 存在共享资源
- 对共享资源有写操作
只要破坏其中任意一个条件,就能避免线程安全问题。
7.2 数据竞争
当多个线程同时修改同一个变量时,由于线程切换,一个线程的修改可能被另一个线程覆盖,导致结果不符合预期。
例如count++,看起来只有一行代码,实际包含三个步骤:
inttemp=count;// 读取count=temp+1;// 修改并写回7.3 经典案例:两个线程各加100万次
publicclassCounter{publicintcount=0;publicvoidincrement(){count++;}}publicclassUnsafeDemo{publicstaticvoidmain(String[]args)throwsInterruptedException{Countercounter=newCounter();Threadt1=newThread(()->{for(inti=0;i<1000000;i++){counter.increment();}});Threadt2=newThread(()->{for(inti=0;i<1000000;i++){counter.increment();}});t1.start();t2.start();t1.join();t2.join();System.out.println(counter.count);// 结果通常小于 200 万}}【面试高频】为什么结果小于200万?因为
count++不是原子操作,多线程下会丢失更新。
7.4 synchronized关键字
Java提供synchronized关键字解决线程安全问题。它可以修饰方法或代码块,保证同一时间只有一个线程执行被锁住的代码。
publicclassSafeCounter{privateintcount=0;publicsynchronizedvoidincrement(){count++;}publicsynchronizedintgetCount(){returncount;}}7.5 synchronized的三种用法
| 用法 | 锁对象 | 示例 |
|---|---|---|
| 同步代码块 | 指定对象 | synchronized(obj) |
| 同步实例方法 | 当前实例this | public synchronized void method() |
| 同步静态方法 | 类的Class对象 | public static synchronized void method() |
7.6 锁对象的选择
多个线程必须使用同一个锁对象,否则 synchronized 不起作用。
// 错误示例:每次new新对象当锁synchronized(newObject()){count++;}上面的写法中,每个线程锁的都是不同的Object对象,等于没有锁。
7.7 解决线程安全的其他方案
| 方案 | 原理 | 适用场景 |
|---|---|---|
| AtomicInteger | CAS无锁机制 | 单个变量计数 |
| ReentrantLock | AQS显式锁 | 需要灵活控制锁 |
| volatile | 保证可见性 | 状态标志位,不适合计数 |
| ThreadLocal | 线程本地变量 | 每个线程独立副本 |
【面试陷阱】
volatile不能保证原子性,不能解决i++的线程安全问题。
八、开发踩坑实录
坑 1:在循环里创建新对象当锁
for(inti=0;i<100;i++){synchronized(newObject()){// 每次锁都不同对象count++;}}后果:锁不住,线程不安全。
正确做法:使用共享对象作为锁。
坑 2:锁对象被重新赋值
privateObjectlock=newObject();publicvoidchangeLock(){lock=newObject();// 锁对象变了!}后果:其他线程可能拿到新的锁对象,导致多把锁同时存在。
正确做法:锁对象用final修饰。
坑 3:用Integer、String等常量当锁
privateStringlock="lock";后果:字符串常量可能被其他类共享,导致意外死锁或性能问题。
正确做法:使用private final Object lock = new Object();
坑 4:递归没有终止条件
publicvoidrecursion(){recursion();}后果:StackOverflowError。
正确做法:递归必须有明确的终止条件。
坑 5:认为start()会立即执行
t1.start();t2.start();后果:调用start()只是把线程交给CPU调度,具体什么时候执行由操作系统决定。
正确做法:不要对线程执行顺序做假设。
九、面试速记卡
9.1 核心概念速查
| 概念 | 一句话解释 |
|---|---|
| 进程 | 资源分配的基本单位,拥有独立内存空间 |
| 线程 | CPU调度的基本单位,共享进程资源 |
| 虚拟机栈 | 每个线程私有,控制方法调用执行流程 |
| 堆 | 所有线程共享,存放对象实例 |
| 方法区 | 存储类信息、常量、静态变量 |
| 程序计数器 | 记录线程当前执行位置 |
| 并发 | 宏观同时、微观交替 |
| 并行 | 真正同时执行 |
9.2 高频面试题
- Java程序从编译到执行的完整流程是什么?
- JVM内存区域有哪些?分别存储什么?哪些线程共享?
- 进程和线程的区别是什么?
- 虚拟机栈的作用是什么?栈帧包含哪些内容?
- 什么是线程安全?出现线程安全问题需要满足哪些条件?
synchronized修饰普通方法和静态方法时,锁对象分别是什么?volatile能保证线程安全吗?为什么?i++是线程安全的吗?为什么?- 递归调用过程中栈是如何变化的?什么情况下会栈溢出?
- 并发和并行有什么区别?
9.3 口诀总结
编译加载再执行,main方法是入口 方法区里存类图,堆里对象大家用 每个线程私有栈,程序计数记行踪 多线程有共享写,线程安全要同步 synchronized上把锁,锁对象必须相同 volatile可见不原子,计数还得用Atomic十、培训小结
本次培训从Java程序执行流程讲起,依次讲解了CPU执行原理、JVM内存模型、虚拟机栈与方法调用、进程与线程、多线程并发、线程安全问题。
重点掌握:
- Java程序从
.java到执行的完整流程 - JVM五大内存区域及其作用
- 线程共享区域和线程私有区域
- 虚拟机栈后进先出的执行机制
- 进程与线程的本质区别
- 线程安全三条件与
synchronized基础用法
理解这些底层原理,是读懂框架源码、通过技术面试、写出高质量并发代码的基础。