文章目录
- 前言:Jvm 整体组成
- 一.JDK的内存区域变迁
- Java8虚拟机启动参数
- 二.堆
- 0.堆的概念
- 1.堆的内存分区
- 2.堆与GC
- 2.1.堆的分代结构
- 2.2.堆的分代GC
- 2.3.堆的GC案例
- 2.4.堆垃圾回收方式
- 3.什么是内存泄露
- 4.堆栈的区别
- 5.堆、方法区 和 栈的关系
- 三.虚拟机栈
- 0.虚拟机栈概念
- 1.线程栈的结构
- 2.栈帧
- 局部变量表(Local Variable Table)
- 操作数栈(Operand Stack)
- 动态链接
- 方法返回地址/方法出口
- 3.栈帧与函数调用
- 4.栈帧与局部变量表
- 四.本地方法栈
- 五.程序计数器(PC寄存器/指令切换器)
- 六.元空间
- 注意:方法区是一种概念,而永久代和元空间是它的2种实现方式。
- 1.元空间概念
- 2.为什么要使用元空间取代永久代的实现
- 七.拓展—直接内存
- 八.对象创建
- 1.对象组成
- 2.Java中提供的几种对象创建方式
- 3.对象创建的主要流程
- 4.对象内存分配2种方式
- 5.处理并发安全问题
前言:Jvm 整体组成
Jvm由4个部分
组成,分为2个子系统和2个组件,2个子系统为Class loader(类装载)、Execution engine(执行引擎);2个组件为Runtime Data Area(运行时数据区)、Native Interface(本地接口)。
- Class loader(类加载器):根据给定的全限定名类名(如:Java.lang.Object)来装载class文件到Runtime data area中的method area。
- Runtime Data Area(运行时数据区域):这就是我们常说的Jvm的内存。
- Execution Engine(执行引擎) :执行classes中的指令。
- Native Interface(本地接口) :与native libraries交互,是其它编程语言交互的接口。
各个组成部分的用途:
- 首先通过编译器把
Java 代码转换成字节码
(class文件) - 类加载器(ClassLoader) 再把字节码加载到内存中将其放在 运行时数据区(Runtime data area) 的
方法区
内 - 字节码文件只是 Jvm 的一套
指令集规范
,并不能直接交给底层操作系统
去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令,再交由CPU 去执行
,而这个过程中需要调用其他语言的 本地库接口(Native Interface) 来实现整个程序的功能。
一.JDK的内存区域变迁
HotSpot虚拟机是是Sun/OracleJDK和OpenJDK中的默认Java虚拟机,是JVM应用最广泛的一种实现。
-
JDK1.6
时期和我们上面讲的JVM内存区域是一致的:
-
JDK1.7
时发生了一些变化,将字符串常量池、静态变量,存放在堆上
- 在
JDK1.8
时彻底干掉了方法区
,而在直接内存
中划出一块区域作为元空间
,运行时常量池、类常量池都移动到元空间
。
- JVM 运行时数据区的 5 个部分中,只有
Java 堆、元空间
是线程共享的,其他三个均为线程私有
Java8虚拟机启动参数
-Xms设置堆的最小空间大小。
堆中 年轻代和年老默认有个比如 是 NewRatio = 2 (默认是 2:1)
年轻代中eden和suvivor默认有个比例 8:1:1 (SurvivorRatio = 8) jps查看进程 jmap -heap 进程编号 查看到改参数
-Xmx 设置堆的最大空间大小。
-XX:NewSize 设置年轻代最小空间大小。
-XX:MaxNewSize 设置年轻代最大空间大小。
-XX:PermSize 设置永久代最小空间大小。
-XX:MaxPermSize 设置永久代最大空间大小。
-Xss 设置每个线程的堆栈大小 (64位 默认是1M -XX:ThreadStackSize默认是0)。
-Xms:JVM启动时申请的初始Heap值,默认为操作系统物理内存的1/64,例如-Xms20m
-Xmx:JVM可申请的最大Heap值,默认值为物理内存的1/4,例如-Xmx20m,我们最好将 -Xms 和 -Xmx 设为相同值,避免每次垃圾回收完成后JVM重新分配内存;
-Xmn:设置新生代的内存大小,-Xmn 是将NewSize与MaxNewSize设为一致,我们也可以分别设置这两个参数
-XX:PermSize 设置最小空间
-XX:MaxPermSize 设置最大空间
-XX:MetaspaceSize :分配给类元数据空间(以字节计)的初始大小。MetaspaceSize的值设置的过大会延长垃圾回收时间。垃圾回收过后,引起下一次垃圾回收的类元数据空间的大小可能会变大。
-XX:MaxMetaspaceSize:分配给类元数据空间的最大值,超过此值就会触发Full GC,此值默认没有限制,但应取决于系统内存的大小。JVM会动态地改变此值。
-XX:MinMetaspaceFreeRatio:表示一次GC以后,为了避免增加元数据空间的大小,空闲的类元数据的容量的最小比例,不够就会导致垃圾回收。
-XX:MaxMetaspaceFreeRatio:表示一次GC以后,为了避免增加元数据空间的大小,空闲的类元数据的容量的最大比例,不够就会导致垃圾回收。
二.堆
- 通常需要程序员调试分析的区域就是“运行时数据区”,或者更具体的来说就是“运行时数据区”里面的
Heap(堆)模块
0.堆的概念
在虚拟机启动时创建 , 堆是被所有线程共享
的最大的一块内存
,几乎所有的对象实例都在这里分配内存(并不是绝对);
- 特点:线程共享
- 异常规定: 如果在堆中没有内存完成实例分配,并且堆不可以再扩展时,将会抛出OutOfMemoryError。 通过
-Xmx
和-Xms
控制堆大小
1.堆的内存分区
根据Java回收机制的不同,Java堆有可能拥有不同的结构。最为常见的一种构成是将整个java堆分为年轻代和老年代
。其中年轻代存放新生对象或者年龄不大的对象,老年代则存放老年对象。
-
年轻代有分为Eden区、s0区、s1区,s0区和s1区也被称为
from和to区
,他们是两块大小相同、可以互换角色
的内存空间。结构:年轻代(Eden区+2个Survivor区) 老年代 永久代(HotSpot有)
在绝大多数情况下,对象首先分配在Eden区
,在一次年轻代回收之后,如果对象还存活,则进入s0或者s1
,每经过一次年轻代回收,对象如果存活,它的年龄就会加1
。当对象的年龄达到一定阀值后,就会被认为是老年对象
,从而进入老年代
。
年轻代:新创建的对象——>Eden区
- GC的时候会将
Eden中存活的对象复制
⼀个空的 Survivor中
,并把当前的 Eden和正在使 的Survivor中的不可达对象 清除掉- 再次GC同上,也是将Eden、Survivor存活对象转移到另一个一个空的Survivor中,然后清理剩余的不可达对象
老年代:对象如果在年轻代存活了足够长的时间而没有被清理掉
(即在几次Young GC
后存活了下来),则会被复制到老年代
- 如果新创建对象比较大(比如
长字符串或大数组
),且年轻代空间不足
,则大对象会直接分配到老年代上(大对象可能触发提前GC,应少用,更应避免使用短命的大对象) 老年代的空间一般比年轻代大,能存放更多的对象
,在老年代上发生的GC次数也比年轻代少
永久代:可以简单理解为方法区(本质上两者并不等价)
- JDK1.6及之前:常量池分配在永久代
- JDK1.7:有,但已经逐步“去永久代”
- JDK1.8及之后:没有永久代(
Java.lang.OutOfMemoryError: PermGen space
,这种错误将不会出现在JDK1.8中),通过使用本地内存的元空间
来代替永久代
2.堆与GC
2.1.堆的分代结构
- 所有的
对象
和它们相应的实例变量
以及数组
将被存储在这里。每个Jvm同样只有一个堆区。由于方法区和堆区的内存由线程共享
,所以存储的数据是非线程安全
的。
堆由年轻代和老年代组成,年轻代又分为Eden区
和survivor(幸存)区,survivor区
中又有from
区和to
区.
-
new出来的对象一般都放在Eden区,那当Eden区满了之后呢?
- 假设通过参数给堆分配
600M内存
,那么老年代默认是占2/3
的,也就是差不多400M
,那年轻代就是200M
,Eden区160M
,Survivor区40M
。
- 假设通过参数给堆分配
2.2.堆的分代GC
一个程序只要在运行,那么就不会不停的new对象,那么总有一刻Eden区会放满
,那么一旦Eden区被放满之后,虚拟机会干什么呢?
- 没错,就是gc,不过这里的gc属于
minor(咪呢) gc
,就是垃圾收集,来收集垃圾对象并清理的,那么什么是垃圾对象呢?
这里就涉及到了一个GC Root根以及可达性分析算法的概念,也是面试偶尔会被问到的。
可达性分析算法
是将GC Roots对象作为起点,从这些起点开始向下搜索引用的对象
,找到的对象都标记为非垃圾对象
,其余未标记的都是垃圾对象
。
加粗样式那么GC Roots根对象又是什么呢?
- GC Roots根就是判断一个对象是否可以回收的依据,只要能通过GC Roots根向下一直搜索能搜索到的对象,那么这个对象就不算垃圾对象,而可以作为GC Roots根的如:
线程栈的本地变量、静态变量、本地方法栈的变量等等它们引用的对象
,说白了就是找到和根节点有联系的对象就是有用的对象,其余都认为是垃圾对象来回收
。
- 经历了第一次
minor gc
后,没有被清理的对象就会被移到From区
,如上图。
- 上面在说对象组成的时候有写到,在对象头的Mark Word中有
存储GC分代年龄
,一个对象每经历一次gc,那么它的gc分代年龄就会+1,如上图。
-
那么如果
第2次
新的对象又把Eden区放满了,那么又会执行minor gc
,但是这次会连着From区一起gc
,然后将Eden区
和From区
存活的对象都移到To区域
,对象头中分代年龄都+1
,如上图。
-
那么当
第3次
Eden区又满的时候,minor gc
就是回收Eden区
和To
区域了,TEden区和To区域
还活着的对象就会都移到From区
,如上图。- 说白了就是
Survivor区中总有一块区域是空着的
,存活的对象存放是在From区和To区轮流存放,也就是互相复制拷贝,这也就是垃圾回收算法中的复制-回收算法
。
- 说白了就是
如果一个对象经历了一个15次gc
的时候,就会移至老年代。如果还没有到最大年龄且From区或者To区域也慢了,就会直接移到老年代
,这只是举例了两种常规规则,还有其他规则也是会把对象存放至老年代的。
- 那么随着应用程序的不断运行,老年代最终也是会满的,那么此时也会gc,此时的gc就是
Full gc
了。
那当我们老年代满了会发生什么呢?当然是我们上面说过的Full GC
,但是你仔细看我们写的这个程序,我们所有new出来的HeapTest对象都是存放在heapLists中的,那就会被这个局部变量
所引用,那么Full GC就不会有什么垃圾对象可以回收
,可是内存又满了,那怎么办?OOM
2.3.堆的GC案例
下面是个死循环,不断的往list中添加new出来的对象。
public class HeapTest {
byte[] a = new byte[1024 * 100];
public static void main(String[] args) throws InterruptedException {
ArrayList<HeapTest> heapTest = new ArrayList<>();
while(true) {
heapTest.add(new HeapTest());
Thread.sleep(10);
}
}
}
通过JDK自带的Jvm调优工具jvisualvm
观察上面代码执行的内存结构。
打开visual GC
- 其中 老年代(Old),伊甸园区(Eden),S0(From),S1(To) 几个区的内存 和 动态分配图都是清晰可见,以一对应的
我们选择中间一张图给大家对应一下上面所讲的内容:
- 对象放入Eden区
- Eden区满发生minor gc
- 第二步的存活对象移至From(Survivor 0)区
- Eden区再满发生minor gc
- 第四步存活的对象移至To(Survivor 1)区
- 这里可以注意到
From和To区
和我们上面所说一致,总有一个是空的
。
可以看到老年代这里,都是一段一段的直线,中间是突然的增加,这就是在minor gc
中一批一批符合规则的对象被批量移入老年代。
2.4.堆垃圾回收方式
-
Minor GC(YGC): 它主要是用来对
年轻代
进行垃圾回收的方式,使用的复制算法
,因为年轻代的对象大多数生命周期很短,所以GC的频率也会比较频繁,但是回收速度很快。 -
Major GC(YGC): 它是主要用于对
老年代
对象的垃圾回收方式,老年代的对象生命周期都是比较长的,所以对象不会轻易灭亡,Major GC的频率不会像Minor GC那么频繁,况且一次Full GC会比Minor GC需要花费更多的时间、消耗更大,通常出现一次Major GC一般也会出现一次Minor GC(但不绝对)。 -
Full GC(): Full GC是针对整
个年轻代、老生代、元空间(metaspace,java8以上版本取代perm gen)的全局范围的GC,
但是它并不等于Major GC + Minor GC,具体是要看使用什么垃圾收集器组合。一次Full GC 需要花费更多的时间、消耗更大,所以要尽可能减少Full GC的次数
。 -
特点比较:
- Minor GC使用
复制算法
,需要一块空的内存空间,所以空间使用效率不高
,但是它不会出现空间碎片的问题。 - 而Full GC一般是采用
标记-清除算法
,容易产生空间碎片
,如果再有对象需要请求连续的空间而无法提供时,会提前触发垃圾回收,
所以它适合存活对象较多
的场景使用也就是老年代
的垃圾回收。
- Minor GC使用
3.什么是内存泄露
-
内存泄漏是
不再被使用的对象或者变量一直被占据在内存中
。理论上来说,Java是有GC垃圾回收机制的,也就是说,不再被使用的对象,会被GC自动回收掉,自动从内存中清除。- 但也有特例即:
长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄露
,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就是Java中内存泄露的发生场景。
- 但也有特例即:
4.堆栈的区别
物理地址
-
堆的物理地址分配对对象是
不连续的
。因此性能慢些。在GC的时候也要考虑到不连续的分配
,所以有各种算法。比如,标记-消除,复制,标记-压缩,分代(即年轻代使用复制算法,老年代使用标记——压缩) -
栈使用的是
数据结构中的栈
,后进先出
的原则,物理地址分配是连续
的。所以性能快。
内存分别
-
堆因为是
不连续的
,所以分配的内存是在运行期
确认的,因此大小不固定
。一般堆大小远远大于栈。 -
栈是
连续的
,所以分配的内存大小要在编译期
就确认,大小是固定的
。
存放的内容
-
堆存放的是
对象的实例和数组
。因此该区更关注的是数据的存储
-
栈存放:
局部变量,操作数栈,返回结果
。该区更关注的是程序方法的执行
。
程序的可见度
- 堆对于线程都是共享、可见的。
- 栈只是线程私有的。他的生命周期和线程相同。
5.堆、方法区 和 栈的关系
该代码声明了一个类,并在main方法中创建了两个SimpleHeap实例。
public class SimpleHeap {
private int id;
public SimpleHeap(int id){
this.id = id;
}
public void show(){
System.out.println("My id is "+id);
}
public static void main(String[] args) {
SimpleHeap s1 = new SimpleHeap(1);
SimpleHeap s2 = new SimpleHeap(2);
s1.show();
s2.show();
}
}
各对象和局部变量的存放情况如下图:
SimpleHeap实例
本身分配在堆
中,描述SimpleHeap类的信息
存放在方法
区,main函数中的s1 s2局部变量
存放在java栈
上,并指向堆中2个实例。
三.虚拟机栈
0.虚拟机栈概念
是Java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame)
用于存储局部变量表、操作数栈、动态链接、方法出口
等信息,每个方法从调用直至执行完成的过程,都对应着一个栈帧
在虚拟机栈中入栈到出栈
的过程。
每一个线程都有一个私有的Java栈,一个线程的Java栈在线程创建的时候被创建
java栈中保存着栈帧
信息- 局部变量表:存放了编译器可知的
各种基本数据类型
(boolean、byte、char、short、int、float、long、double)、对象引用(引用指针,并非对象本
),- 局部变量表所需的内存空间在
编译期间完成分配
,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量是完全确定的,在运行期间栈帧不会改变局部变量表的大小空间
)
- 特点:线程私有
- 异常规定:StackOverflowError、OutOfMemoryError
- 如果线程
请求的栈深度
大于虚拟机所允许的栈深度
就会抛出StackOverflowError - 如果虚拟机内存是可以动态扩展的,如果扩展时无法申请到足够的内存就会抛出OutOfMemoryError
- 如果线程
JVM 会在线程被创建时,创建一个线程私有
的虚拟机栈,也叫“线程栈
”。该栈的生命周期和线程是一致
,除了Native方法以外,Java方法都是通过Java 虚拟机栈来实现调用和执行
过程的(需要程序计数器、堆、元空间内数据的配合)。所以Java虚拟机栈是虚拟机执行引擎的核心之一。而Java虚拟机栈中出栈入栈的元素就称为「栈帧」
。
- 每个线程栈由
多个栈帧(Frame)
组成,对应着每个方法
运行时所占用的内存
。 - 每个线程只能有一个
活动栈帧
,也叫当前栈帧
,对应着当前正在执行的方法
,当方法执行时压入栈
,方法执行完毕后弹出栈
。 - 方法体中的
基本类型
的变量都在栈上,引用变量
的指针在栈上,实例在堆上
。
1.线程栈的结构
public class Math {
public static int initData = 666;
public static User user = new User();
public int compute() {
int a = 1;
int b = 2;
int c = (a+b) * 10;
return c;
}
public static void main(String[] args) {
Math math = new Math();
math.compute();
System.out.println("test");
}
}
- 每个方法都有自己的局部变量,如图中main方法中的
math,compute
方法中的a b c
,那么Java虚拟机为了区分不同方法中局部变量作用域范围的内存区域,每个方法在运行的时候都会分配一块独立的栈帧内存区域, 上图中的程序代码执行的内存活动如下。
-
执行main方法中的第1行代码是,栈中会分配
main()
方法的栈帧,并存储math局部变量,,接着执行compute()
方法,那么栈又会分配compute()的栈帧区域。- 当
compute()
方法执行完之后,就会出栈被释放,也就符合先进后出
的特点,后调用的方法先出栈。
- 当
2.栈帧
栈帧(Stack Frame)
是用于支持虚拟机进行方法调用和方法执行的数据结构。
-
栈帧存储了方法的
局部变量表、操作数栈、动态连接和方法返回地址等信息
**。 -
每一个方法从调用至执行完成的过程,都对应着一个栈帧在虚拟机栈里从
入栈到出栈
的过程**。 -
简单的理解就是:
栈对应线程,栈帧对应方法
- 栈帧主要由4个部分组成。
- 栈帧主要由4个部分组成。
局部变量表(Local Variable Table)
局部变量表(Local Variable Table)是一组变量值存储空间
,用于存放方法参数和方法内定义的局部变量
。包括8种基本数据类型、对象引用(reference类型)和returnAddress类型 (指向一条字节码指令的地址)。
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出
StackOverflowError
异常 - 如果虚拟机栈动态扩展时无法申请到足够的内存时会抛出
OutOfMemoryError
异常。
直接上代码
public int test(int a, int b) {
Object obj = new Object();
return a + b;
}
- 如果局部变量是Java的8种基本数据类型,则存在局部变量表中,如果是
引用类型
。如new出来的String,局部变量表中存的是引用
,而实例在堆中
。
操作数栈(Operand Stack)
操作数栈(Operand Stack) 也称作操作栈,是一个后入先出栈(LIFO)
。随着方法执行
和字节码指令
的执行,会从局部变量表或对象实例的字段
中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素 出栈到局部变量表 或者 返回给方法调用者,也就是出栈/入栈操作。
public class OperandStackTest {
public int sum(int a, int b) {
return a + b;
}
}
编译生成.class文件之后,再反汇编查看汇编指令
javac OperandStackTest.java
javap -v OperandStackTest.class
OperandStackTest字节码文件
public int sum(int, int);
descriptor: (II)I
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=3 // 最大栈深度为2 局部变量个数为3
0: iload_1 // 局部变量1 压栈
1: iload_2 // 局部变量2 压栈
2: iadd // 栈顶两个元素相加,计算结果压栈
3: ireturn
LineNumberTable:
line 10: 0
动态链接
动态链接:Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池
中 该栈所属方法的符号引用
,持有该引用是为了支持方法调用过程中的动态链接(Dynamic Linking)
。
方法返回地址/方法出口
方法返回地址/方法出口:无论方法是否正常完成,都需要返回到方法被调用的位置
,程序才能继续进行
- 方法执行时有2种退出情况:
- 正常退出,即正常执行到任何方法的返回字节码指令,如 RETURN、IRETURN、ARETURN等
- 异常退出
无论何种退出情况,都将返回至方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧,退出可能有3种方式:
- 返回值压入上层调用栈帧
- 异常信息抛给能够处理的栈帧
- PC 计数器指向方法调用后的下一条指令
那么要讲这个就会涉及到更底层的原理–字节码
。我们先看下我们上面代码的字节码文件。
看着就是一个16字节的文件
,看着像乱码,其实每个都是有对应的含义的,oracle官方是有专门的Jvm字节码指令手册
来查询每组指令对应的含义的。那我们研究的,当然不是这个。
- JDK有自带一个
javap
的命令,可以将上述class文件生成一种更可读的字节码文件
。 - 我们使用
javap -c
命令将class文件反编译并输出到TXT文件中。
Compiled from "Math.java"
public class com.example.demo.test1.Math {
public static int initData;
public static com.example.demo.bean.User user;
public com.example.demo.test1.Math();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public int compute();
Code:
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: bipush 10
9: imul
10: istore_3
11: iload_3
12: ireturn
public static void main(java.lang.String[]);
Code:
0: new #2 // class com/example/demo/test1/Math
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method compute:()I
12: pop
13: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
16: ldc #6 // String test
18: invokevirtual #7 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
21: return
static {};
Code:
0: sipush 666
3: putstatic #8 // Field initData:I
6: new #9 // class com/example/demo/bean/User
9: dup
10: invokespecial #10 // Method com/example/demo/bean/User."<init>":()V
13: putstatic #11 // Field user:Lcom/example/demo/bean/User;
16: return
}
其中方法中的指令还是有点懵,我们举compute()方法来看一下:
Code:
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: bipush 10
9: imul
10: istore_3
11: iload_3
12: ireturn
- 这几行代码就是对应的我们代码中
compute()
方法中的四行代码。大家都知道越底层的代码,代码实现的行数越多,因为他会包含一些java代码在运行时底层隐藏的一些细节原理。那么一样的,这个Jvm指令官方也是有手册可以查阅的,网上也有很多翻译版本,大家如果想了解可自行百度。
0. 将int类型常量1压入操作数栈
0: iconst_1
1. 将int类型值存入局部变量1
1: istore_1
- 局部变量1,在我们代码中也就是第一个
局部变量a
,先给a在局部变量表
中分配内存,然后将int类型的值,也就是目前唯一的一个1存入局部变量a
2. 将int类型常量2压入操作数栈
2: iconst_2
3. 将int类型值存入局部变量2
3: istore_2
4. 从局部变量1中装载int类型值
4: iload_1
5. 从局部变量2中装载int类型值
5: iload_2
- 这两个代码是将局部变量1和2,也就是a和b的值装载到
操作数栈
中
6. 执行int类型的加法
6: iadd
iadd
指令一执行,会将操作数栈
中的1和2
依次从栈底弹出并相加
,然后把运算结果3在压入操作数栈底。
7. 将一个8位带符号整数压入栈
7: bipush 10
- 这个指令就是将10压入栈
8. 执行int类型的乘法
9: imul
- 这里就类似上面的加法了,将
3和10弹出栈
,把结果30压入栈
9. 将将int类型值存入局部变量3
10: istore_3
- 这里大家就不陌生了吧,和第2步第3步是一样的,
将30存入局部变量3,也就是c
10. 从局部变量3中装载int类型值
11: iload_3
- 这个前面也说了
11. 返回int类型值
12: ireturn
- 这个就不用多说了,就是
将操作数栈中的30返回
到这里就把我们compute()方法讲解完了,讲完有没有对局部变量表和操作数栈的理解有所加深呢?说白了赋值号=后面的
就是操作数,在这些操作数进行赋值,运算的时候需要往内存存放,那就是存放在操作数栈
中,作为临时存放操作数的一小块内存区域。
接下来我们再说说方法出口。
- 方法出口说白了不就是
方法执行完了之后要出到哪里
,那么我们知道上面compute()方法执行完之后应该回到main()方法第三行
那么当main()方法调用compute()的时候,compute()栈帧
中的方法出口就存储了当前要回到的位置,那么当compute()方法执行完之后,会根据方法出口中存储的相关信息回到main()方法的相应位置。
3.栈帧与函数调用
- 如下图:函数1中调用函数2,函数2中调用函数3,函数3调用函数4。当函数1被调用时,栈帧1入栈,当函数2调用时,栈帧2入栈。。。以此类推。当前正在执行的函数所对应的帧就是
当前帧(位于栈顶)
,它保存着当前函数的局部变量、中间计算结果等数据
。
- 当函数返回时,栈帧从java栈中被弹出,java方法区有2种返回函数的方式,一种是正常的函数返回,使用
return指令
,另一种是抛出异常
。不管使用哪种方式,都会导致栈帧被弹出。- 每次函数调用都会产生对应的栈帧,占用一定的栈内存,如果栈内存不足,当
请求的栈深度大于最大可用栈深度时
,系统会抛出StackOverflowError栈溢出错误
。
- 每次函数调用都会产生对应的栈帧,占用一定的栈内存,如果栈内存不足,当
使用递归,由于递归没有出口,这段代码可能会抛出栈溢出错误,在抛出栈溢出错误时,打印最大的调用深度
public class TestStackDeep {
private static int count =0;
public static void recursion(){
count ++;
recursion();
}
public static void main(String[] args) {
try{
recursion();
}catch(Throwable e){
System.out.println("deep of calling ="+count);
e.printStackTrace();
}
}
}
- 使用参数-Xss128K执行上面代码
在进行大约1079次调用之后,发生了栈溢出错误,通过增大-Xss的值,可以获得更深的层次调用,尝试使用参数-Xss256K执行上述代码,调用层次有明显的增加:
结论:函数嵌套调用的层次在很大程度上由栈的大小决定
,栈越大,函数支持的嵌套调用次数就越多。
4.栈帧与局部变量表
局部变量表是栈帧的组成部分之一。用于保存函数的参数
以及局部变量
,局部变量表随着函数栈帧的弹出而销毁。
- 如果函数的参数和局部变量很多 或 很大,会使得
局部变量表膨胀
,从而每一次函数调用就会占用更多的栈空间
,最终导致函数的嵌套调用次数减少
。
例如:一个recursion()函数含有3个参数和10个局部变量,因此,其局部变量表含有13个变量,而第二个recursion()函数不再含有任何参数和局部变量,当这两个函数被嵌套调用时,第二个recursion函数可以拥有更深的调用层次。
public class TestStackDeep2 {
private static int count = 0;
public static void recursion(long a,long b,long c){
long e=1,f=2,g=3,h=4,i=5,k=6,q=7,x=8,y=9,z=10;
count ++;
recursion(a,b,c);
}
public static void recursion(){
count++;
recursion();
}
public static void main(String[] args) {
try{
recursion(0L,0L,0L);
//recursion();
}catch(Throwable e){
System.out.println("deep of calling = "+count);
e.printStackTrace();
}
}
}
- 使用虚拟机参数
-Xss128K
递归执行上述代码中的recursion(long a,long b,long c)函数
,输出结果为:
- 使用虚拟机参数
-Xss128K
递归执行不带参数的recursion()
函数
四.本地方法栈
与虚拟机栈的作用是一样的
,只不过虚拟机栈是服务 Java 方法的,而本地方法栈是为虚拟机调用本地方法(Native方法)服务的
- 特性和异常: 同虚拟机栈,请参考上面知识点。即
线程私有,StackOverflowError、OutOfMemoryError
。
new Thread().start();
public synchronized void start() {
if (threadStatus != 0)
throw new IllegalThreadStateException();
group.add(this);
boolean started = false;
try {
start0();
started = true;
} finally {
try {
if (!started) {
group.threadStartFailed(this);
}
} catch (Throwable ignore) {
}
}
}
其中底层调用了一个start0()
的方法,本地方法,底层通过C语言
实现的
private native void start0();
那java代码里为什么会有C语言实现的本地方法呢?
-
大家都知道JAVA出来之前一个公司的系统百分之九十九都是使用C语言实现的,但是java出现后,很多项目都要转为java开发,那么新系统和旧系统就免不了要有交互,那么就需要本地方法来实现了,底层是调用C语言中的
dll库文件
,就类似于java中的jar包
,当然,如今跨语言的交互方式就很多了,比如`thrift,http接口方式,webservice等,当时并没有这些方式,就只能通过本地方法来实现了。- 那么本地方法始终也是方法,每个线程在运行的时候,如果有运行到本地方法,那么必然也要产生局部变量等,那么就需要存储在本地方法栈了。如果没有本地方法,也就没有本地方法栈了。
五.程序计数器(PC寄存器/指令切换器)
程序计数器/*PC寄存器(Program Counter Register):可以看做是当前线程所执行的字节码的行号指示器
,每个线程都有一个程序计数器来保存当前执行指令的地址
,一旦该指令被执行,程序计数器会被更新至下条指令的地址
。程序计数器是Java虚拟机规定的唯一不会发生内存溢出的区域
。
-
这是一块
较小
的内存空间(可忽略不记
),用于记录当前线程所执行的字节码的行号指示器
,字节码解析器的工作是通过改变这个计数器的值,来选取下一条需要执行的字节码指令
,分支、循环、跳转、异常处理、线程恢复等基础功能,都需要依赖这个计数器
来完成; -
特点:线程私有
-
异常规定:无
那么Jvm虚拟机为什么要设置程序计数器这个结构呢?
-
因为Jvm的多线程是通过
线程轮流切换并分配处理器执行时间(cpu时间片)来的方式来实现的
,也就是任何时刻,一个处理器(对于多核处理器来说是一个内核)
都只会执行一条线程中的指令。因此为了线程切换后能恢复到正确的执行位置
,每个线程都有独立的程序计数器
。它被设计出来的目的,是为了让多线程情况下的JAVA程序每个线程都能够正常的工作,每个线程都有自己的程序计数器,用于保存线程的执行情况,这样在进行线程切换的时候就可以在上次执行的基础上继续执行了
六.元空间
注意:方法区是一种概念,而永久代和元空间是它的2种实现方式。
1.元空间概念
方法区即我们常说的永久代(Permanent Generation), 也称为非堆(No-Heap)、是线程共享的一块内存区域,用于存储被 JVM 加载的类信息、常量、静态变量、JIT即时编译器编译后的代码
等数据
- 运⾏时常量池: 运⾏时常量池是⽅法区的⼀部分,Class 文件中除了有 类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量池,用于存放编译期生成的各种
字面量和符号引用
,这部分内容将在类加载后存放到方法区的运行时常量池中 - 字⾯量 : 字符串(
JDK 8 移动到堆中
)、final常量、基本数据类型的值(如Integer,管理-128–127的常量。)。 - 符号引⽤ : 类和结构的完全限定名、字段的名称和描述符、⽅法的名称和描述符。
JDK1.8后永久代被元空间代替,元空间存储在直接内存(系统内存)
,而不在虚拟机当中(不受JVM最大运行内存的限制,只和本地内存的大小有关
) 其他内容比如类元信息、字段、静态属性、方法、常量等都移动到元空间区
。
-
特点:线程共享
-
异常规定:当方法无法满足内存分配需求时会抛出OutOfMemoryError异常。
- 默认最小值为16MB,最大值为64MB,可以通
过-XX:PermSize 和 -XX:MaxPermSize
参数限制方法区的大小
- 默认最小值为16MB,最大值为64MB,可以通
-
运行时常量池是方法区的一部分,用于存放编译器生成的各种
字面量和符号引用
。
-
JDK1.6字符串常量池在
方法区
中,1.7将放在方法区
的字符串常量池放到堆
中。**在JDK1.8时彻底去掉了永久代的概念,而在直接内存中划出一块区域作为元空间,运行时常量池、类常量池都移动到元空间。
。
2.为什么要使用元空间取代永久代的实现
1.避免OOM:
- 方法区主要是
存储类的相关信息(包括类的字节码文件)
, 虽然永久代可以使用PerSize和MaxPerSize
等参数设置永久代的空间大小, 但随着ASM、Cglib等动态生成字节码技术的出现可以修改对象字节码信息后,无法控制类信息的大小, 因此对于永久代的大小指定比较困难,太小容易出现永久代溢出
,太大则容易导致老年代溢出
,即java.lang.OutOfMemoryError: PermGen
。- JDK1.8使用了元空间替换永久代,因为元空间是使用
系统内存
,由系统的实际可用空间来控制,在一定程度上可以避免OOM的出现,
但是也需要通过指定MaxMetaspaceSize
等参数来控制大小。
- JDK1.8使用了元空间替换永久代,因为元空间是使用
2.提高GC性能:
永久代的垃圾收集是和老年代捆绑在一起的,
所以无论两者谁满了,都会触发永久代和老年代的垃圾收集。
JDK1.7时永久代的部分数据已经从Java的永久代中转移到了堆中,如:符号引用、字符串常量池
- 使用元空间替换后,简化了
Full GC,减少了GC的时间(因为GC时不需要再扫描永久代中的数据),提高了GC的性能
。在元空间中,只有少量指针指向堆,如类的元数据中指向class对象的指针。
- 使用元空间替换后,简化了
3.Hotspot和JRockit合并:
- 官方原因,永久代只是Hotspot虚拟机中存在的概念,JRockit中并没有这个说法,JDK8需要整合Hotspot和JRockit,所以废弃了永久代,引入了元空间。
七.拓展—直接内存
直接内存(Direct Memory): 也叫堆外内存,直接内存并不是Jvm管理的内存,可以这样理解就是Jvm以外的机器内存
,比如,你有4G的内存,Jvm占用了1G,则其余的3G就是直接内存
- 在JDK 1.4中新加入了
NIO(New Input/Output)
类,引入了一种基于通道(Channel)
与缓冲区(Buffer)
的I/O方式,它可以使用Native函数库
直接分配堆外内存
,然后通过一个存储在Java堆
中的DirectByteBuffer对象
作为这块内存的引用进行操作。通- 通常访问直接内存的速度会优于Java堆。因此出于性能的考虑,读写频繁的场合可以考虑使用直接内存,避免了在
Java堆和Native堆
中来回复制数据。- 但系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。
- 通常访问直接内存的速度会优于Java堆。因此出于性能的考虑,读写频繁的场合可以考虑使用直接内存,避免了在
八.对象创建
1.对象组成
对象在内存中存储的布局可以分为3块区域:对象头(Header)
、实例数据(Instance Data)
和 对齐填充(Padding)
HotSpot虚拟机的对象头包括2部分信息:
Mark Word
-
第一部分markword,用于存储
对象自身的运行时数据
,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等, -
对象头的另外一部分是klass
类型指针
(Klass Pointer),即对象指向它的Class类元数据的指针
,虚拟机通过这个指针来确定这个对象是哪个类的实例. -
数组长度(只有数组对象有): 如果对象是一个数组, 那在对象头中还必须有一块数据用于记录数组长度.
实例数据:
- 第二部分实例数据,
是对象真正存储的有效信息
,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。
对齐填充
- 第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍),因此,
当对象实例数据部分没有对齐时,就需要通过对齐填充来补全
。
其中的类型指针
就是那条红色的线,那是怎么联系的呢?
类加载其实最终是以Class对象
的形式存储在方法区
中的,math和math2都是由同一个类new出来的,当对象被new时,都会在对象头中存储一个指向类元信息的指针,这就是Klass Pointer类型指针
2.Java中提供的几种对象创建方式
- 使用
new
关键字->
调用了构造方法 - 使用
Class
的newInstance
方法->
调用了构造方法 - 使用
Constructor
类的newInstance
方法->
调用了构造方法 - 使用
clone
方法->
没有调用构造方法 - 使用
反序列化
->`没有调用构造方法
3.对象创建的主要流程
虚拟机遇到一条new指令时
,先检查常量池是否已经加载相应的类
,如果没有,必须先执行相应的类加载
。类加载通过后,接下来分配内存。若Java堆中内存是绝对规整的,使用 “指针碰撞“ 方式分配内存;如果不是规整的,就从空闲列表中分配,叫做 ”空闲列表“ 方式。
- 分内存时还需要考虑一个问题-并发,也有2种方式:
CAS同步处理
,或者本地线程分配缓冲
(Thread Local Allocation Buffer, TLAB)。然后内存空间初始化操作,接着是做一些必要的对象设置(元信息、哈希码…),最后执行<init>
方法。
4.对象内存分配2种方式
类加载完成后,接着会在Java堆中划分一块内存分配给对象。内存分配根据Java堆是否规整
,有2种方式:
- 指针碰撞: 如果Java堆的内存是
规整
,即所有用过的内存放在一边,而空闲的的放在另一边。分配内存时将位于中间的指针指示器向空闲的内存移动一段与对象大小相等的距离
,这样便完成分配内存工作。 - 空闲列表: 如果Java堆的内存是
不规整
的,已使用的内存和空闲的内存相互交错, 那就没办法简单的进行指针碰撞了, 必须由由虚拟机维护一个列表
来记录那些内存是可用的
,在分配的时候从列表找到一块足够大的内存分配给对象,并在分配后更新列表记录。
5.处理并发安全问题
对象的创建在虚拟机中是一个非常频繁的行为,哪怕只是修改一个指针所指向的位置
,在并发情况下也是不安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。
解决这个问题有两种方案:
- 对分配内存空间的动作进行
同步处理
(采用CAS + 失败重试
来保障更新操作的原子性); - 把内存分配的动作
按照线程划分在不同的空间之中进行
,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲
(Thread Local Allocation Buffer, TLAB)。哪个线程要分配内存,就在哪个线程的 TLAB 上分配。只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁。通过-XX:+/-UserTLAB参数来设定虚拟机是否使用TLAB。
面试题:(Java实习生)每日10道面试题打卡——JVM篇