Java内存模型与多线程并发编程培训

Java内存模型与多线程并发编程培训

开篇:为什么面试总问JVM内存模型?

面试Java岗位时,“JVM内存模型”“进程与线程的区别”"线程安全怎么保证"几乎是必问题。

这些问题考的不是背诵能力,而是你能不能讲清楚:一段Java代码从写出来到跑起来,中间经历了什么?内存里到底发生了什么?多线程为什么会把数据改坏?

本次培训就围绕这三个问题展开。

一、Java程序执行流程

1.1 一段代码是怎么跑起来的

写好的.java文件只是文本,计算机读不懂。必须先编译成.class字节码,再加载到JVM中运行。

整个过程就像翻译一本书:先由javac把中文(源代码)翻译成中间语言(字节码),再由java把中间语言交给本地解释器执行。

1.2 javac与java的分工

# 第一步:编译javac HelloWorld.java# 第二步:运行javaHelloWorld

javac负责把源代码编译成字节码文件。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执行程序时,不断重复三个步骤:

  1. 取指令:从内存中读取下一条指令
  2. 译码:解析这条指令要做什么
  3. 执行:运算器完成具体计算

这个循环非常快,现代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 栈帧结构

每个方法调用对应一个栈帧,栈帧包含四部分:

  1. 局部变量表:存放方法参数和局部变量
  2. 操作数栈:存放运算过程中的中间结果
  3. 动态链接:指向运行时常量池的方法引用
  4. 方法返回地址:方法执行完后回到哪里

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";}}

【面试高频】推荐使用实现RunnableCallable的方式,因为Java是单继承,继承Thread会限制扩展性。

六、多线程与并发

6.1 CPU调度:时间片轮转

CPU在同一时刻只能执行一个任务。操作系统采用时间片轮转算法,让每个线程轮流执行一小段时间。

时间片通常只有几十毫秒。线程在时间片用完后被挂起,CPU切换到另一个线程执行。

6.2 并发与并行的区别

概念定义硬件要求
并发宏观上同时,微观上交替单核CPU即可
并行真正同时执行需要多核CPU

单核CPU只能实现并发,多核CPU才能同时运行多个线程,实现并行。

6.3 多线程的优势与代价

优势

  • 提高CPU利用率
  • 提升程序响应速度
  • 充分利用多核CPU

代价

  • 线程创建和切换有开销
  • 需要处理线程安全问题
  • 增加程序复杂度和调试难度

6.4 上下文切换

线程切换时,CPU需要保存当前线程的执行状态(寄存器、程序计数器等),再加载下一个线程的状态。这个过程叫上下文切换。

频繁的上下文切换会消耗CPU资源。所以线程数量不是越多越好。

七、线程安全问题

7.1 线程安全三条件

多个线程访问共享资源时,只要同时满足以下三个条件,就可能出现线程安全问题:

  1. 多线程环境
  2. 存在共享资源
  3. 对共享资源有写操作

只要破坏其中任意一个条件,就能避免线程安全问题。

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)
同步实例方法当前实例thispublic synchronized void method()
同步静态方法类的Class对象public static synchronized void method()

7.6 锁对象的选择

多个线程必须使用同一个锁对象,否则 synchronized 不起作用。

// 错误示例:每次new新对象当锁synchronized(newObject()){count++;}

上面的写法中,每个线程锁的都是不同的Object对象,等于没有锁。

7.7 解决线程安全的其他方案

方案原理适用场景
AtomicIntegerCAS无锁机制单个变量计数
ReentrantLockAQS显式锁需要灵活控制锁
volatile保证可见性状态标志位,不适合计数
ThreadLocal线程本地变量每个线程独立副本

【面试陷阱】volatile不能保证原子性,不能解决i++的线程安全问题。

八、开发踩坑实录

坑 1:在循环里创建新对象当锁

for(inti=0;i<100;i++){synchronized(newObject()){// 每次锁都不同对象count++;}}

后果:锁不住,线程不安全。

正确做法:使用共享对象作为锁。

坑 2:锁对象被重新赋值

privateObjectlock=newObject();publicvoidchangeLock(){lock=newObject();// 锁对象变了!}

后果:其他线程可能拿到新的锁对象,导致多把锁同时存在。

正确做法:锁对象用final修饰。

坑 3:用IntegerString等常量当锁

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 高频面试题

  1. Java程序从编译到执行的完整流程是什么?
  2. JVM内存区域有哪些?分别存储什么?哪些线程共享?
  3. 进程和线程的区别是什么?
  4. 虚拟机栈的作用是什么?栈帧包含哪些内容?
  5. 什么是线程安全?出现线程安全问题需要满足哪些条件?
  6. synchronized修饰普通方法和静态方法时,锁对象分别是什么?
  7. volatile能保证线程安全吗?为什么?
  8. i++是线程安全的吗?为什么?
  9. 递归调用过程中栈是如何变化的?什么情况下会栈溢出?
  10. 并发和并行有什么区别?

9.3 口诀总结

编译加载再执行,main方法是入口 方法区里存类图,堆里对象大家用 每个线程私有栈,程序计数记行踪 多线程有共享写,线程安全要同步 synchronized上把锁,锁对象必须相同 volatile可见不原子,计数还得用Atomic

十、培训小结

本次培训从Java程序执行流程讲起,依次讲解了CPU执行原理、JVM内存模型、虚拟机栈与方法调用、进程与线程、多线程并发、线程安全问题。

重点掌握:

  • Java程序从.java到执行的完整流程
  • JVM五大内存区域及其作用
  • 线程共享区域和线程私有区域
  • 虚拟机栈后进先出的执行机制
  • 进程与线程的本质区别
  • 线程安全三条件与synchronized基础用法

理解这些底层原理,是读懂框架源码、通过技术面试、写出高质量并发代码的基础。