面试篇之JVM
面试篇之JVM
运行时数据区
美团
1、请简述JVM运行时数据区的组成结构及各部分作用
2、说说程序计数器的作用?
3、代码异常后如何执行?
4、为什么finally总会被执行?
字节
1、java内存区域?局部变量在哪?(堆中栈帧的局部变量表)
题解
总览
java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域,不同的区域存储不同的数据,Java 引以为豪的就是它的自动内存管理机制,相比于 C++的手动内存管理、复杂难以理解的指针等,Java 程序写起来就方便的多。所以要深入理解 JVM 必须理解JVM虚拟内存的结构划分。
如下图:
分线程共享和线程私有两类,或者你也曾见过如下图,大致都是一样的
这样的划分只是JVM的一种规范,至于具体的实现是不是完全按照规范来?这些区域是否都存在?这些区域具体在哪儿?不同的虚拟机不同的版本在实现上略有不同
虚拟机栈
虚拟机栈顾名思义首先是一个栈结构,线程每执行一个方法时都会有一个栈帧入栈,方法执行结束后栈帧出栈,栈帧中存储的是方法所需的数据,指令、返回地址等信息,虚拟机栈的结构如下
1、虚拟机栈是基于线程的:哪怕你只有一个 main() 方法,也是以线程的方式运行的。在线程的生命周期中,参与执行的方法栈帧会频繁地入栈和出栈,虚拟机栈的生命周期是和线程一样的。
2、栈大小:每个虚拟机栈的大小缺省为 1M,
3、堆栈溢出:栈帧深度压栈但并不出栈,导致栈空间不足,抛出java.lang.StackOverflowError ,典型的就是递归调用,
4、栈帧的组成:
栈帧大体都包含四个区域:(局部变量表、操作数栈、动态连接、返回地址),如下图所示:
1、局部变量表:存放我们的局部变量的(方法内的变量)。首先它是一个32 位的长度,主要存放我们的 Java 的八大基础数据类型,一般 32 位就可以存放下,如果是 64 位的就使用高低位占用两个也可以存放下,如果是局部变量是一个对象,存放它的一个引用地址即可。
2、操作数栈:存放 java 方法执行的操作数的,它也是一个栈,操作的的元素可以是任意的 java 数据类型,一个方法刚刚开始的时候操作数栈为空,操作数栈本质上是JVM执行引擎的一个工作区,方法在执行,才会对操作数栈进行操作。
3、动态链接:指向运行时常量池的方法引用,将指令中的符号引用转化为真实的方法地址
4、完成出口:正常返回(调用程序计数器中的地址作为返回)、异常的话(通过异常处理器表<非栈帧中的>来确定)
本地方法栈
本地方法栈和虚拟机栈类似,具备线程隔离的特性,不同的是,本地方法栈服务的对象是JVM执行的native方法,而虚拟机栈服务的是JVM执行的java方法,虚拟机规范里对这块所用的语言、数据结构、没有强制规定,虚拟机可以自由实现它,hotspot把它和虚拟机栈合并成了1个。
程序计数器
较小的内存空间,存储当前线程执行的字节码的偏移量;各线程之间独立存储,互不影响。
方法区
方法区(Method Area)是可供各线程共享的运行时内存区域,主要用来存储已被虚拟机加载的类信息、常量、静态变量、JIT编译器编译后的代码缓存等等,它有个别名叫做:非堆(non-heap),主要是为了和堆区分开。
方法区中存储的信息大致可分以下两类:
1、类信息:主要指类相关的版本、字段、方法、接口描述、引用等
2、运行时常量池:编译阶段生成的常量与符号引用、运行时加入的动态变量
运行时常量池
在jvm规范中,方法区除了存储类信息之外,还包含了运行时常量池。这里
首先要来讲一下常量池的分类常量池可分两类:
1、Class常量池(静态常量池)
2、运行时常量池
3、字符串常量池(没有明确的官方定义,其目的是为了更好的使用String ,真实的存储位置在堆)
堆
1、堆被划分为新生代和老年代( Tenured ),
2、新生代与老年代的比例的值为 1:2 ,该值可以通过参数 –XX:NewRatio
来指定 。
3、新生代又被进一步划分为 Eden 和 Survivor 区, Survivor 由 From Survivor 和 To Survivor 组成,eden,from,to的大小比例为:8:1:1;可通过参数 -XX:SurvivorRatio 来指定
异常和finally
出现异常,会查看异常表,跳到对应的位置。
finally在编译的时候,在各种可能访问到的路径,字节码做了多份,保证都会执行。
对象
美团
1、JVM对象内存布局,new一个对象有多大?
阿里
1、阐述对象的分配策略
Boss直聘
1、new 一个对象都有哪些步骤?(ex: User user = new User() )
题解
对象内存布局如下:
题解
JVM对象内存布局,new一个对象有多大?
对象内存布局如下:
普通对象,8字节对象头,4字节类型指针,然后是实例数据和行对其
数组会多4字节记录数组长度
64位操作系统,对象头是8字节,但因为开启了指针压缩,所以变成4字节
阐述对象的分配策略
对象的创建过程,如下图:
分配内存
对象的访问:句柄和直接指针
对象的分配策略
整体策略如下图所示:
逃逸分析
长期存活的对象进入老年代:
HotSpot 虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。 为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中( markword )。
有以下几点要注意:
1、如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被
Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1,对象在 Survivor 区中每熬过一次 Minor GC ,年龄就增加 1,当它的年龄增加到一定程度(并发的垃圾回收器默认为 15),CMS 是 6 时,就会被晋升到老年代中。
2、可以通过参数: -XX:MaxTenuringThreshold=threshold 调整
3、为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了 MaxTenuringThreshold 才能晋升老年代,如果在Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold 中要求的年龄。
空间分配担保:
1、在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么 Minor GC 可以确保是安全的。
2、如果不成立,则虚拟机会查看 -XX:HandlePromotionFailure 设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC ,尽管这次 Minor GC 是有风险的,如果担保失败则会进行一次Full GC ;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那这时也要改为进行一次 Full GC 。
字节码
美团
1、说说异常时是如何保证锁释放的
快手
1、符号引用是什么?
小米
1、拆箱/装箱的原理?
2、字符串拼接的优化?
题解
说说异常时是如何保证锁释放的
加锁解锁 monitorenter monitorexit
方法有异常表,如果同步代码块中有异常,去查异常表,找到异常后要执行的字节码
编译一部分正常执行的,另一部分出现异常执行的,
符号引用是什么?
.java编译成class的时候,那个时候还不是内存中的实际对象,那怎么表示?符号引用
加载到内存后,变成真实的对象了,这个时候会变成直接引用
拆箱/装箱的原理
Integer.valueOf
调用包装类的方法,编译的时候已经编译到字节码中了
字符串拼接的优化
类加载
美团
1、JVM类加载机制说一下
题解
一个java类在整个运行时大致会经过如下阶段:
加载
“加载 loading”是整个类加载(class loading)过程的一个阶段,加载阶段虚拟机需要完成以下 3 件事情:
1)通过一个类的全限定名来获取定义此类的二进制字节流。
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3)在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
注意:
- 加载的字节码来源,不一定非得是class文件,可以是符合字节码规范 的任意地方,甚至二进制流等
- 从字节码到内存,是由加载器(ClassLoader)完成的,下面我们详细 看一下加载器相关内容
验证
例如:
1、文件格式验证(版本号,是不是CAFEBABYE开头,…) 2、元数据验证(验证属性、字段、类关系、方法等是否合规)
3、字节码验证
4、符号引用验证
准备
为class中定义的各种类变量(静态变量)分配内存,并赋初始值,注意是
对应类型的初始值,赋具体值在后面的初始化阶段。注意!即便是static变量,
它在这个阶段初始化进内存的依然是该类型的初始值!而不是用户代码里的初
始值。
看下面两个实例:
//类变量:在准备阶段为它开辟内存空间,但是它是int的初始值,也就是 0,而真正123的赋值,是在下面的初始化阶段
public static int a = 123;
//类成员变量(实例变量)的赋值是在类对象被构造时才会赋值
public String address = "北京"
//final修饰的类变量,编译成字节码后,是一个ConstantValue类型,在准备阶段,直接给定值123,后期也没有二次初始化一说
public static final int b = 123;
那 static 变量什么时候赋具体的业务值呢?在类加载的最后一步:初始化阶段。
解析
将常量池内的符号引用替换为直接引用的过程
初始化
类加载的最后一个步骤,经过这个步骤后,类信息完全进入了jvm内存,直到它被垃圾回收器回收
1、前面几个阶段都是虚拟机来搞定的。我们也干涉不了,从代码上只能遵从它的语法要求。而这个阶段,是初始化赋值,java虚拟机才真正开始执行类中编写的java程序代码,将主导权移交给应用程序。
2、在准备阶段,静态变量已经赋过一次系统要求的初始值了,而在初始化阶段要执行初始化函数 函数,注意 并不是程序员在代码中编写的,而是由 javac 编译器自动生成的,
3、 函数是由编译器自动收集类中的所有静态变量的赋值动作和静态语句块( static 代码块)中的语句合并产生的。
4、 函数与类的构造函数(虚拟机视角的 函数)是不同的, 函数是在运行期创建对象时才执行,而 在类加载的时候就执行了。
5、虚拟机能保障父类的 函数优先于子类 函数的执行。
6、在 函数中会对类变量赋具体的值,也就是我们说的:
public static int a = 123;
这行代码的123才真正赋值完成。
双亲委派机制
快手
1、双亲委派机制的过程及作用,三种类加载器,加载过程,双亲委派机制能打
破吗?
题解
类加载器
类加载器做的事情就是上面 5 个步骤的事(加载、验证、准备、解析、初
始化)
java提供了3个系统加载器,分别是 Bootstrp ClassLoader、ExtClassLoader 、AppClassLoader
这三个加载器在定义上不构成继承关系,但是从逻辑上构成父子关系。
BootstrapClassLoader(启动类加载器)
Bootstrp加载器是用 C++ 语言写的,它在Java虚拟机启动后初始化,它主要负责加载以下路径的文件:
- %JAVA_HOME%/jre/lib/*.jar
- %JAVA_HOME%/jre/classes/*
- -Xbootclasspath 参数指定的路径
这一步会加载一个关键的类: sun.misc.Launcher ,这个类包含了两个静态内部类: ExtClassLoader , AppClassLoader ,如下:
public Launcher() {
// Create the extension class loader
ClassLoader extcl;
try {
extcl = ExtClassLoader.getExtClassLoader();
} catch (IOException e) {
throw new InternalError(
"Could not create extension class loader", e);
}
// Now create the class loader to use to launch the application
try {
loader = AppClassLoader.getAppClassLoader(extcl);
} catch (IOException e) {
throw new InternalError(
"Could not create application class loader", e);
}
由于启动类加载器是由C++实现的,所以在Java代码里面是访问不到启动类加载器的,如果尝试通过 String.class.getClassLoader() 获取启动类加载器的引用,会返回 null
ExtClassLoader(标准扩展类加载器)
ExtClassLoader 是用 Java 写的,具体来说就是sun.misc.Launcher$ExtClassLoader ExtClassLoader 主要加载:
- %JAVA_HOME%/jre/lib/ext/*
- ext 下的所有 classes 目录
- java.ext.dirs 系统变量指定的路径中类库
AppClassLoader(系统类加载器)
AppClassLoader 也是用Java写成的,它的实现类是
sun.misc.Launcher$AppClassLoader ,另外我们知道 ClassLoader 中有个
getSystemClassLoader 方法,此方法返回的就是它。
- 负责加载 -classpath 所指定的位置的类或者是jar文档
- 也是Java程序默认的类加载器
UserClassLoader(用户自定义类加载器)
java编写,用户自定义的类加载器,可加载指定路径的class文件
双亲委派模型
类加载器加载某个类的时候,因为有多个加载器,甚至可以有各种自定义的,他们呈父子关系。这给人一种印象,子类的加载会覆盖父类,其实恰恰相反!
与普通类继承属性不同,类加载器会优先调父类的 loadClass 方法,如果父类能加载,直接用父类的,否则最后一步才是自己尝试加载,从源代码上可
以验证。
双亲委派模型的实现在: ClassLoader.loadClass() 方法中:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
// 首先,检测是否已经加载
Class<?> c = findLoadedClass(name);
if (c == null) {
//如果没有加载,开始按如下规则执行:
long t0 = System.nanoTime();
try {
if (parent != null) {
//重点!父加载器不为空则调用父加载器的 loadClass
c = parent.loadClass(name, false);
} else {
//没有父加载器也会先让Bootstrap加载器 去加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
//父加载器没有找到,则调用findclass,自己 查找并加载
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
PerfCounter.getParentDelegationTime().addTime(t1 - t0);
PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
为什么这么设计
避免重复加载、 核心类篡改
采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有
优先级的层次关系,通过这种层级关可以避免类的重复加载,当父加载器已经
加载了该类时,就没有必要子加载器再加载一次。
其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为 java.lang.Integer 的类,通过双亲委派模型传递到启动类加载器,而启动类加载器发现这个名字的类,发现该类已被加载,就不会重新加载网络传递过来的 java.lang.Integer ,而直接返回已加载过的Integer.class ,这样便可以防止核心API库被随意篡改。
双亲委派能否打破
答案是可以的。
1、tomcat
tomcat通过 war 包进行应用的发布,它其实是违反了双亲委派机制原则,
简单看一下tomcat类加载的层次结构如下:
通过自定义加载器的过程,我们知道,实现自定义的classloader,需要重新loadClass以及findClass,我们先看
比如:Tomcat的 webappClassLoader 加载web应用下的class文件,不会
传递给父类加载器,
问题:tomcat的类加载器为什么要打破该模型?
首先一个tomcat启动后是会起一个jvm进程的,它支持多个web应用部署
到同一个tomcat里,为此
1、对于不同的web应用中的class和外部jar包,需要相互隔离,不能因为
不同的web应用引用了相同的jar或者有相同的class导致一个加载成功了另一个
加载不了。
2、web容器支持jsp文件修改后不用重启,jsp文件也是要编译成.class文件的,每一个jsp文件对应一个JspClassLoader,它的加载范围仅仅是这个jsp文件所编译出来的那一个.class文件,当Web容器检测到jsp文件被修改时,会替换掉目前JasperLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件的热部署功能。