JVM: 运行时数据区
文章目录
- JVM: 运行时数据区
-
- 运行时数据区
-
- 程序计数器
-
-
-
- 为什么需要程序计数器?
- 为什么每个线程都需要一份私有的程序计数器?
-
-
- 虚拟机栈
-
-
- 为什么会出现虚拟机栈?
- 什么是java虚拟机栈?优点?
- 既然会内存空间不足异常,那么如何设置栈大小来改变某些特定环境下的要求?
- 栈的存储单位
-
- 栈帧内部结构
- 虚拟机相关面试题:
-
- 举例栈溢出的情况?(StackOverflowError)
- 调整栈大小,就能保证不出现溢出吗?
- 垃圾回收是否会设计到虚拟机栈?
- 方法中定义的局部变量是否线程安全
-
- 本地方法接口,本地方法库
-
-
- 什么是本地方法?
- 作用
-
- 本地方法栈
-
-
- 什么是本地方法栈
-
- 堆
-
- jvm内存的逻辑细分
-
- 分代的标准
- 堆空间大小设置
- 内存分配策略
-
- 区别Minor GC、 Major GC 、Full GC、Mixed GC
- 面试题:堆空间一定是所有线程共享的嘛?
- 代码优化
-
- 栈上分配
- 面试题:堆是唯一分配对象存储的选择吗?
- 同步省略
- 分离对象或标量替换
- 方法区(元空间)
-
-
-
- 设置元空间大小(jdk8)
-
- 运行时常量池
-
- 常量池的好处
-
- 类中各种属性的加载赋值时间小总结
-
- 对象实例化
-
- 创建对象的方式
- 创建对象的步骤
- 对象访问定位
-
- 对象访问的两种方式
运行时数据区
描述了程序运行的实时状态;虚拟机栈,本地方法栈,和程序计数器是每一个线程私有。堆和方法区是线程共享。
程序计数器
是当前线程所执行的字节码的行号指示器,是用来存储执行下一条指令的地址,也就是将要执行的指令代码。由执行引擎读取下一条指令。
为什么需要程序计数器?
CPU进行线程切换或者被其他工作打断之后再次回到线程执行的时候,需要知道上一次执行到哪个位置,而程序计数器就记录了程序执行的位置,所以可以快速的定位到上一次执行中断的位置,继续执行。
为什么每个线程都需要一份私有的程序计数器?
一个进程中会有多个线程,并且各个线程之间可以进行频繁的切换,如果我们只用一个程序计数器可能会导致不同线程之间执行的字节码地址相互覆盖相互干扰,所以最好的解决办法就是么个线程都拥有一份程序计数器,各自急速正在执行的字节码地址。
虚拟机栈
为什么会出现虚拟机栈?
java架构拥有跨平台性,指令集小,编译器易于实现的优点,不同平台CPU的架构是不相同的,所以为了实现跨平台设计,java的指令流是根据栈的结构来设计的,将每一个方法放入栈中,然后从栈顶 开始执行,执行完就出栈。但是这样的架构设计也存在一些缺陷,那就是性能相对于基于寄存器的设计来说比较慢,实现相同的功能所需要的指令更多等。
什么是java虚拟机栈?优点?
栈是运行时的单位,它是线程私有的,生命周期和线程一致;保存了方法的局部变量(包括8中基本数据类型,对象的引用地址),部分结果,参与方法的调用返回。
作为一种分配存储方式,访问速度仅此于程序计数器,只有进栈出栈两个过程,不存在垃圾回收问题,但是存在溢出异常和内存不足异常的问题。
既然会内存空间不足异常,那么如何设置栈大小来改变某些特定环境下的要求?
2021版本之前,
run ==》edit Configurations
2021版本idea,
help ==> custom VM options
栈的存储单位
栈的基本存储单位是栈帧。
不同线程中所包含的栈帧是不允许存在相互引用的,栈是线程私有的,相互隔离的。
方法结束的两种方式:
- 正常返回结束
- 出现未捕获异常,以抛出异常的方式结束异常
栈帧内部结构
- 局部变量表
- 就是一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量。
- 所需容量在编译期确定,所以运行期间不会改变其大小。
- 因为是线程私有,所以不会存在线程安全问题。
- 局部变量表的基本存储单元是slot,每一个slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值。
- 32位以内包括32位的类型占一个slot(引用类型),64位的占2个slot(long,double),当访问两个slot的局部变量的时候,访问前一个slot的索引即可
- byte,short,char在存储前都被转换为int,BOOlean也会转换成int。
- 如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this会存放在index为0的slot处,其余参数依次排列。
- slot可以重复利用,当一个局部变量出了他的作用域范围就会被销毁,另外的局部变量就会可以占据他的位置,从而实现重复利用。
- 操作数栈(表达式栈)
- 保存计算过程的中间结果,同时作为计算过程变量的临时存储空间。
- 在方法执行过程中,根据字节码指令,可能会往栈中写入数据或者是提取数据。
- 32位数据类型占1个栈空间,64位占2个栈空间。
- 如果被调用的方法带有返回值,其返回值也会被压入当前栈帧的操作数栈中。
- 动态链接(执行运行时常量池的方法引用)
- 每个栈帧内部都有一个指向运行时常量池中该栈帧所属方法的引用。
- 将这些符号引用转换为调用方法的直接引用。
- 常量池的作用就是提供一些符号和常量,便于指令识别。
- 早期绑定
- 被调用的方法在编译器可知,且运行期不变时,即可将这个方法所属类型进行绑定,明确了被调用的目标方法是哪一个,因此可以使用静态链接的方式将符号引用转换为直接引用。
- 晚期绑定
- 编译器无法确定,只能在运行期间根据实际类型绑定相关的方法。
- 举例:多态问题。当我们调用类型为向上转型后的类型中的方法时,因为在编译器无法预知具体调用的方法,只有在运行期根据实际情况来确定。
- 编译器无法确定,只能在运行期间根据实际类型绑定相关的方法。
class Animal { public void eat(){ System.out.println("动物在吃");; } } class Cat extends Animal { public void eat(){ System.out.println("猫吃鱼"); super.eat(); } } class Dog extends Animal { public void eat(){ System.out.println("狗吃骨头"); } } public class testAnimal(Animal animal) { animal.eat() //晚期绑定 }
- 非虚方法
- 私有方法,静态方法,实例构造方法,父类中的方法(必须显式的用super来调用才算),final修饰的方法 (invokestatic调用静态方法,invokespecial调用,私有,父类方法),编译器就确定。
- 虚方法
- 在编译器不确定的方法。(invokevirtual:调用所有虚方法;invokeinterface:调用所有接口方法)
- 方法返回地址
- 存放调用该方法的pc寄存器的值。
- 将pc寄存器的值返回交给执行引擎。
- 返回指令包括ireturn(boolean,byte,char,short,int类型时使用)、lreturn、lreturn、freturn 、dreturn以及areturn,另外还有一个return指令供声明为void的方法、实例初始化方法、类和接口的初始化方法。
- 附加信息
虚拟机相关面试题:
举例栈溢出的情况?(StackOverflowError)
当一个栈空间加载栈帧,由于栈帧过多,就会出现栈溢出的情况,可以通过-Xss设栈的大小,就是扩容;当整体空间不足就会出现内存溢出问题。
调整栈大小,就能保证不出现溢出吗?
不能。因为内存空间是有限的,而程序执行可以无限循环,当达到一定的限度,就一定会沾满栈空间。只能让溢出出现的时候推迟,而不能避免。
垃圾回收是否会设计到虚拟机栈?
不会。前面就已经讲过,虚拟机栈存在error但是不存在GC(垃圾回收);
方法中定义的局部变量是否线程安全
前面提到过,说因为局部变量是线程私有的,所以不会存在线程安全问题。但是,这不是绝对的,大多数情况是这样的,我们需要知道哪些情况会发生线程不安全问题,这也是面试官想考的点。当局部变量在一个方法中被创建,然后在方法中被销毁,那么这个局部变量就是线程安全的 ;如果一个变量在方法中被创建,但是作为返回值返回出该方法,那么就有可能有其他的线程会对这个返回值进行操作,就可能存在线程安全问题。(提醒一下:方法内部需要保持原子性才能实现同步安全,可以通过锁实现)
本地方法接口,本地方法库
什么是本地方法?
被native关键字修饰的方法,本地方法只被定义,具体实现不是由java语言实现,使得其他语言的代码也能为java所用。
作用
就是通过java调用其他语言的代码实现一些java实现起来相对较为困难的需求。实际运用得少,了解即可。
本地方法栈
什么是本地方法栈
对比java虚拟机栈,java虚拟机栈是用来管理java方法的调用的,而本地方法栈自然就是用于管理本地方法的调用的。他同样是线程私有的。其他信息也可虚拟机栈大同小异。
堆
Java堆是java虚拟机所管理内存中最大的一块内存空间,处于物理上不连续的内存空间,只要逻辑连续即可,主要用于存放各种类的实例对象。该区域被所有线程共享,在虚拟机启动时创建,用来存放对象的实例,几乎所有的对象以及数组都在这里分配内存(栈上分配、标量替换优化技术的例外)。
jvm内存的逻辑细分
- JVM内存划分为堆内存和非堆内存,堆内存分为年轻代(Young Generation)、老年代(Old Generation),非堆内存就一个永久代(Permanent Generation)【注:jdk1.8中称为元空间】。
- 年轻代又分为Eden和Survivor区。Survivor区由FromSpace和ToSpace组成。Eden区占大容量,Survivor两个区占小容量,默认比例是8:1:1(实际情况中,我们计算得到的比例并不是8:1:1,要想变成8:1:1,使用显式指定:-XX:SurvivorRtio=8)。
- 堆内存用途:存放的是对象,垃圾收集器就是收集这些对象,然后根据GC算法回收。
- 非堆内存用途:永久代,也称为方法区,存储程序运行时长期存活的对象,比如类的元数据、方法、常量、属性等。
分代的标准
将对象根据存活概率进行分类,对存活时间长的对象,放到固定区,从而减少扫描垃圾时间及GC频率。针对分类进行不同的垃圾回收算法,对算法扬长避短。
堆空间大小设置
堆空间大小在jvm启动时就已经设定好了。
-Xms表示堆区起始内存,等价于-XX:InitialHeapSize. 默认默认电脑内存大小/64。
-Xmx表示堆区的最大内存,等价于-XX:MaxHeapSize. 默认物理电脑内存大小/4。
-NewRatio:设置新生代老年代的比例。默认是1/2。
-xmn: 设置新生代的空间大小。
绝大部分的java对象都是在堆区中的Eden区被创建出来的,而且大部分的对象也都是在这个区被销毁。
内存分配策略
新生成的对象首先放到Eden区,当Eden空间满了,就会触发YGC,存活下来的对象移动到from(Survivor0)区,Eden区满后再次触发执行Minor GC,from(Survivor0)区存活对象也会移动到 to (Suvivor1)区,这样保证了一段时间内总有一个survivor区为空。经过多次Minor GC达到晋升的阈值的时候,仍然存活的对象就会被放入到老年代。
老年代存储长期存活的对象,占满时会触发Major GC,GC期间会停止所有线程等待GC完成,所以对响应要求高的应用尽量减少发生Major GC,避免响应超时。
补充:因为Eden:s0:s1=8:1:1,当s0或者s1满的时候,并不会触发ygc,只有Eden满了才会触发ygc;当Eden满了之后会触发YGC,这时会连同s区一起进行回收。如果出现s区满了或者相同年龄的对象大小之和大于空间的一半就会特殊处理,直接放入老年代;当出现一个大对象在新生代放不下,也会直接放入老年代,老年代空间不够则进行major GC
区别Minor GC、 Major GC 、Full GC、Mixed GC
Minor GC:仅仅是指新生代收集(Eden,s0,s1)。
Major GC:指老年代收集。
Mixed GC:混合收集,收集整个新生代和部分老年代。
Full GC:收集整个java堆和方法区的垃圾收集。
面试题:堆空间一定是所有线程共享的嘛?
不是,在新生区的伊甸园区 存在一个TLAB,这个空间是每个线程自己私有的,约为伊甸园区的1%,这各区域主要是用来存放每个线程私有的一些对象,当这个区域放不小才会放入Eden共享的区域。
代码优化
栈上分配
面试题:堆是唯一分配对象存储的选择吗?
如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。什么是逃逸分析呢?当一个对象在方法内部定义,对象只在方法内部使用,那么就叫没有发生逃逸,就可以优 化为栈上分配;如果对象被外部方法引用(作为调用参数传递到其他地方),就认为发生逃逸。
同步省略
通过他逃逸分析判断同步块所使用的锁对象是否只能被一个线程访问而没有被发布到其他线程,那么就可以取消对这段代码的同步,提高并发性。也就是说,当一个对象只能被一个线程访问,那么就没必要加锁。
public void test() {
Test t = new Test();
synchronized (t) {
System.out.println(t);
}
}
例如,上面的代码,即使是高并发的情况下,t也不会被两个线程使用,因为每个线程进入这个方法都会新建一个t,所以就没必要对这个对象进行加锁。
分离对象或标量替换
有的对象可能不需要作为一个连续内存结构存在也可以访问到,那么对象的部分或者全部可以不用存储在内存,而是存储在CPU的寄存器(栈)中。
通过逃逸分析,发现如果对象没有逃逸,那么就可以将对象进行标量替换,分解成若干个标量,就想当于转换成了多个局部变量存放在局部变量表中。
方法区(元空间)
在某些场景下,为永久代设置空间大小是很难确定的,如果动态加载类过多,容易产生Perm区的oom。空间过小,会造成full GC,使得用户进程受阻,拖慢程序性能。过大又会造成大量浪费。所以将类的元数据信息移到了一个与堆不相连的本地内存区域,这个区域就叫做元空间。由于类的元数据信息被分配到了本地内存中,元空间能分配的最大内存就是我们的本地内存。至此,永久代永久退出历史舞台,元空间由此诞生。
方法区中存储了被虚拟机加载的类型信息,常量,即时编译器编译后的代码缓存等。
在jdk1.6之前,静态变量的引用,字符串常量池都是存放在永久代上的。
设置元空间大小(jdk8)
-XX:MetaspaceSize=100m -XX:MaxMetaspaceSize=100m
运行时常量池
Java中的常量池,分为两种:静态常量池和运行时常量池。
静态常量池,就是class文件中的常量池,class文件中的常量池既包含字符串(数字)字面量,也包含类、方法的信息,占用class文件绝大部分空间。这种常量池主要用于存放两大类常量:字面量和符号引用量,字面量相当于Java语言层面常量的概念,如文本字符串,声明为final的常量值等,符号引用则属于编译原理方面的概念,包括了如下三种类型的常量:
- 类和接口的全限定名
- 字段名称和描述符
- 方法名称和描述符
运行时常量池则是jvm虚拟机在完成类装载操作后,将class文件中的常量池载入到内存中,并保存在方法区中,我们常说的常量池,就是指方法区中的运行时常量池。
运行时常量池相对于CLass文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入CLass文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用比较多的就是**String类的intern()**方法。
String的intern()方法会查找在常量池中是否存在一份equal相等的字符串,如果有则返回该字符串的引用,如果没有则添加自己的字符串进入常量池。
常量池的好处
常量池是为了避免频繁的创建和销毁对象而影响系统性能,其实现了对象的共享。
例如字符串常量池,在编译阶段就把所有的字符串文字放到一个常量池中。
(1)节省内存空间:常量池中所有相同的字符串常量被合并,只占用一个空间。
(2)节省运行时间:比较字符串时,比equals()快。对于两个引用变量,只用判断引用是否相等,也就可以判断实际值是否相等。
类中各种属性的加载赋值时间小总结
- 单独用final修饰的变量也有可能在字节码找到对应的ConstantValue属性,但是会被JVM忽略掉。
- final修饰的实例属性,在实例创建的时候才会赋值。
- static修饰的类属性,在类加载的准备阶段赋初值,初始化阶段赋值。
- static+final修饰的String类型或者基本类型常量,JVM规范建议在初始化阶段赋值,但是HotSpot VM直接在准备阶段就赋值了。
- static+final修饰的其他引用类型常量,赋值步骤和第二点的流程是一样的。
对象实例化
创建对象的方式
- new 关键词
- 单例模式中通过静态方法创建。
- xxxBuilder/xxxFactory的静态方法。
- 反射Class的newInstance():【只能调用空参构造器,而且权限必须是public】
- 反射Constructor的newInstance(xxx):【也是反射,可以调用有参构造器,权限无要求】
- 使用clone():不需要任何构造器,当前类需要实现Cloneable()接口,实现clone()方法。
- 使用反序列化:将网络中或者是本地中的对象二进制流还原为一个对象。
- 第三方库。
创建对象的步骤
- 判断对象对应的类是否加载、连接、初始化。
- 到元空间的运行时常量池中定位到一个类的符号引用,并检查这个类是否被加载,连接,初始化(检查类的元信息是否存在)。如果没有则在双亲委派模式下,使用加载器进行查找对应的class文件,没有找到就报classnotfoundexception,找到就进行加载。
- 为对象分配内存
- 内存规整—指针碰撞
- 内存不规整—虚拟机维护一个列表;空间列表进行分配
- 处理并发安全问题
- 采用CAS失败重试、区域加锁保证更新的原子性。
- 每个线程预先分配一块TLAB–通过 --XX:+/-UseTlab参数设定。
- 初始化分配到的空间。
- 设置对象的对象头。
- 运行时元数据:哈希值,GC分代年龄,锁状态标志,线程持有锁,偏向线程ID,偏向时间戳。
- 类型指针:指向类元数据instanceklass,确定该对象所属类型。
- 执行init方法进行初始化。
对象访问定位
对象访问的两种方式
- 句柄访问
- 堆区中存在一块句柄池区域,里面存放的是到对象实例数据的指针,指向堆中的对象实例,还有一个到对象类型数据的指针,指向元空间的对象类型数据。而在栈帧的局部变量表中就存放了该实例在句柄池中的位置,从而找到对象。
- 直接指针
- 局部变量表中直接保存了堆中对象实例数据,堆中的实例数据中包含了元空间中对象类型数据所在的位置。