Android面试:Java虚拟机JVM

一、JVM基本构成?

1)类加载器(ClassLoader):在JVM启动时或者在类运行时需要将class文件转换成字节码加载到运行时数据区(Runtime Data Area)。
2)运行时数据区(Runtime Data Area):是在JVM运行的时候操作所分配的内存区。运行时内存区主要可以划分为6个区域,程序计数器(Program Counter Register)、Java虚拟机栈(Java Virtual Machine Stack)、本地方法栈(Native Method Stack)、Java(Java Heap)、方法区(Methed Area)、直接内存(Direct Memory)。
3)执行引擎(Execution Engine):负责将class字节码翻译成底层系统指令再交由CPU去执行,而这个过程中需要调用其他语言(主要是C和C++)的接口本地库接口(Native Interface)来实现。
4)本地库接口(Native Interface):主要是调用C或C++实现的本地方法及回调结果。

二、Java内存模型JMM?

Java内存模型即Java Memory Model,简称JMM。JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。Java虚拟机所管理的内存主要包括5个区域,线程私有的数据区包括程序计数器虚拟机栈本地方法栈线程共享的数据区包括Java方法区(JDK1.8 之后的元空间)

1)程序计数器(Program Counter Register):程序计数器是线程私有的一块较小的内存空间,其可以看做是当前线程所执行的字节码的行号指示器。如果线程正在执行的是一个 Java 方法,计数器记录的是正在执行的字节码指令的地址;如果正在执行的是 Native 方法,则计数器的值为空。
2)虚拟机栈(Java Virtual Machine Stack):虚拟机栈描述的是Java方法执行的内存模型,是线程私有的。每个方法在执行的时候都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。抛StackOverflowError和OutOfMemoryError异常。
3)本地方法栈(Native Method Stack):本地方法栈与Java虚拟机栈非常相似,也是线程私有的,区别是虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈为虚拟机执行 Native 方法服务。抛StackOverflowError和OutOfMemoryError异常。
4)(Java Heap):Java 堆的唯一目的就是存放对象实例,几乎所有的对象实例(和数组)都在这里分配内存。是垃圾收集器管理的主要区域,故也称为称为GC堆。抛OutOfMemoryError异常。
5)方法区(Methed Area):方法区与Java堆一样,也是线程共享的并且不需要连续的内存,其用于存储已被虚拟机加载的 类信息、常量、静态变量、即时编译器编译后的代码等数据。包含运行时常量池。抛OutOfMemoryError异常。

除了程序计数器,其他的部分都会发生 OOM。

JVM 内存模型概述

三、什么是直接内存(Direct Memory)?

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。也叫Java堆外内存

在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。Java中对堆外内存的操作,依赖于Unsafe提供的操作堆外内存的native方法(Java魔法类:Unsafe应用解析)。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

直接内存分配不会受到Java堆大小的限制,但是受到本机总内存大小限制,在设置虚拟机参数的时候,不能忽略直接内存,把实际内存设置为-Xmx,使得内存区域的总和大于物理内存的限制,从而导致动态扩展时出现OutOfMemoryError异常。

四、Java类加载过程?

一个类的完整生命周期如下:

类加载过程包括五个步骤:加载验证准备解析初始化,其中验证准备解析统称为连接阶段。如下:

1.加载

将class文件加载到Java虚拟机中,并为这个类在方法区创建对应的 Class 对象加载阶段和连接阶段的部分内容是交叉进行的。这一步主要完成下面三件事:

1.通过全类名获取定义此类的二进制字节流
2.将字节流所代表的静态存储结构转换为方法区的运行时数据结构
3.在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口

2.验证

确保加载进来的class文件包含的信息符合Java虚拟机的规范;

3.准备

为类的static变量在方法区分配内存;将上述变量的初始值设置为0而非开发者定义的值(特殊情况:若static变量加了final,则值被设置为开发者定义的值); 

4.解析

将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符7类符号引用进行。

符号引用:字符串,能根据这个字符串定位到指定的数据,比如java/lang/StringBuilder
直接引用:内存地址

5.初始化

初始化静态变量和静态代码块。主要有下面两件事:

1)生成类构造器<clinit>()方法。<clinit>()方法由编译器自动收集静态变量和静态代码块合并产生。
2)执行<clinit>()方法。虚拟机会保证在子类类构造器<clinit>()执行之前,父类的类构造<clinit>()执行完毕。由于父类的构造器<clinit>()先执行,也就意味着父类中定义的静态代码块/静态变量的初始化要优先于子类的静态代码块/静态变量的初始化执行。静态代码块/静态变量的初始化顺序与代码书写的顺序一致。特别地,类构造器<clinit>()对于类或者接口来说并不是必需的,如果一个类中没有静态代码块,也没有对类变量的赋值操作,那么编译器可以不为这个类生产类构造器<clinit>()。

卸载:卸载类即该类的Class对象被GC。

JavaGuide:类加载过程

(JVM)Java虚拟机:类加载的5个过程

书呆子Rico:深入理解Java对象的创建过程:类的初始化与实例化

五、Java类的加载机制(双亲委派)?

双亲委派机制:即在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载时,首先将加载任务委托给父类加载器,依次递归 (本质上就是loadClass函数的递归调用),因此所有的加载请求最终都应该传送到顶层的启动类加载器中。如果父类加载器可以完成这个类加载请求,就成功返回;只有当父类加载器无法完成此加载请求时,子加载器才会尝试自己去加载(源码查看ClassLoader.loadClass(String name, boolean resolve))。

类加载器:BootstrapClassLoader(启动类加载器)、ExtensionClassLoader(扩展类加载器)、ApplicationClassLoader(应用程序类加载器)、自定义类加载器

public class ClassLoader {
    @Override
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        // 检查需要加载的类是否已经被加载过
        Class<?> c = findLoadedClass(name);
        
        if (c == null) {
            try {
                // 若没有加载,则调用父加载器的loadClass()方法加载
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    // 若父类加载器为空,则使用启动类加载器BootstrapClassLoader加载
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 若父类加载器加载失败会抛出ClassNotFoundException, 
                //说明父类加载器无法完成加载请求 
            }
            if (c == null) {
                // 在父类加载器无法加载时 
                // 再调用本身的findClass方法进行类加载 
                c = findClass(name);
            }
        }
        
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

双亲委派机制优点?
1)避免同一个类被重复加载(缓存);
2)避免Java 的核心 API 被篡改;

如何自定义类加载器?
继承ClassLoader,并重写findClass(String name)。

自定义类加载器使用场景?
1)加密:由于java代码很容易被反编译,如果需要对自己的代码加密的话,可以对编译后的代码进行加密,然后再通过实现自己的自定义类加载器进行解密,最后再加载:
2)从非标准的来源加载代码:如果你的字节码是放在数据库、甚至是在云端,就可以自定义类加载器,从指定的来源加载类:
3)以上两种情况在实际中的综合运用。

如果我们不想用双亲委派模型怎么办?
为了避免双亲委托机制,我们可以自己定义一个类加载器,然后重写loadClass()即可。

双亲委派模型不是一种强制性约束,也就是你不这么做也不会报错怎样的,它是一种JAVA设计者推荐使用类加载器的方式。
有些情况不得不违反这个约束,例如JDBC,它是面向扩展的(SPI)。

JavaGuide:类加载器
(JVM)Java虚拟机:(双亲委派模型)类加载器全解析
深入理解Java类加载器(一):Java类加载原理解析
面试官:说说双亲委派模型?

六、Java对象创建过程?

1)类加载检查:检查对应的类是否已被加载完成。虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程;
2)分配内存:把一块确定大小的内存从 Java 堆中划分出来。分配方式根据堆内存是否规整有指针碰撞(规整)和空闲列表(不规整)两种,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定;
3)初始化零值:虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值;
4)设置对象头:虚拟机对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式;
5)执行 init 方法:在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

JavaGuide:Java内存区域
JVM:Java对象的创建、内存布局 & 访问定位 全过程解析

七、对象的访问定位有哪两种方式?

创建对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有使用句柄直接指针两种:

1)句柄:如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;

2)直接指针:如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。

这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

八、Java堆内存中对象分配的基本策略?

堆空间基本结构:

目前主流的垃圾收集器都会采用分代回收算法,因此需要将堆内存分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

1)新生代(Young Generation)
新生代内存按照 8:1:1 的比例分为一个eden区和两个survivor(survivor0survivor1)区。大部分情况,对象都会首先在eden区域分配。当eden区没有足够空间进行分配时,虚拟机将发起一次新生代GC。在进行新生代GC时,先将eden区存活对象复制到survivor0区,然后清空eden区,当这个survivor0区也满了时,则将eden区和survivor0区存活对象复制到survivor1区,然后清空eden和这个survivor0区,此时survivor0区是空的,然后交换survivor0区和survivor1区的角色(即下次垃圾回收时会扫描Eden区和survivor1区),即保持survivor0区为空,如此往复。每经过一次立即回收,对象的年龄加1。特别地,当survivor1区也不足以存放eden区和survivor0区的存活对象时,或者存活的对象的年龄超过阈值(可以通过参数 -XX:MaxTenuringThreshold 来设置,默认15)时, 就将存活对象直接存放到老年代。

2)老年代(Old Generation)
老年代的内存也比新生代大很多(大概比例是1:2)。老年代存放的大部分是一些生命周期较长的对象。另外,为了避免为大对象分配内存时由于分配机制带来的复制而降低效率,大对象直接进入老年代。当老年代满时会触发老年代GC。

九、Minor GC 和 Full GC

1)新生代 GC(Minor GC):指发生新生代的的垃圾收集动作,Minor GC 非常频繁耗时较短
2)老年代 GC(Major GC/Full GC):指发生在老年代的 GC,发生频率较低,出现了 Major GC 经常会伴随至少一次的 Minor GC(并非绝对),Major GC 耗时较长,一般会比Minor GC慢10倍以上。

Full GC与Major GC的争议?

Major GC通常是跟full GC是等价的。
知乎:Major GC和Full GC的区别是什么?

十、如何判断对象需回收?

1)引用计数法:通过判断对象的引用数量来决定对象是否可以被回收,任何引用计数为0的对象实例可以被当作垃圾收集。引用计数法效率高,但很难解决对象之间相互循环引用的问题,因此目前主流的虚拟机中并没有选择这个算法来管理内存。

2)可达性分析算法:可达性分析算法是以“GC Roots”对象作为起点判断对象的引用链是否可达来决定对象是否可以被回收。可达性分析算法流程:
第一次标记:对象在经过可达性分析后发现没有与GC Roots有引用链,则进行第一次标记并进行一次筛选,筛选条件是:该对象是否有必要执行finalize()方法。没有覆盖finalize()方法或者finalize()方法已经被执行过都会被认为没有必要执行。 如果有必要执行:则该对象会被放在一个F-Queue队列,并稍后在由虚拟机建立的低优先级Finalizer线程中触发该对象的finalize()方法,但不保证一定等待它执行结束,因为如果这个对象的finalize()方法发生了死循环或者执行时间较长的情况,会阻塞F-Queue队列里的其他对象,影响GC。
第二次标记:GC对F-Queue队列里的对象进行第二次标记,如果在第二次标记时该对象又成功被引用,则会被移除即将回收的集合,否则会被回收。

运行时常量池主要回收的是废弃的常量,如何判断一个常量是废弃常量?

假如在常量池中存在字符串 "abc",如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 "abc" 就是废弃常量,如果这时发生内存回收的话而且有必要的话,"abc" 就会被系统清理出常量池。

方法区主要回收的是无用的类,如何判断一个类是无用的类?

类需要同时满足下面 3 个条件才能算是“无用的类”:
1)该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例;
2)加载该类的ClassLoader已经被回收;
3)该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

十一、垃圾回收算法有哪些?

1)标记-清除算法(Mark-Sweep):分为标记和清除两个阶段,首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。它是最基础的收集算法,后续的算法都是对其不足进行改进得到。

标记-清除算法主要有两个缺点:
效率问题:标记和清除的效率都不高;
空间问题:容易产生大量内存碎片,大致后续没有足够的连续内存分配给较大对象,从而提前触发GC。

2)复制算法(Copying):将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这种算法每次都是对整个半区进行内存回收,内存分配时也不用考虑内存碎片问题,代价是使用内存为原来的一般。这种算法适用于对象存活率低的场景,比如新生代。事实上,现在商用的虚拟机都采用这种算法来回收新生代,在实践中会把新生代内存划分为块较大的Eden空间和两块较小的Survivor空间。

复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,还会浪费50%空间。

3)标记-整理算法(Mark-Compact):这种算法的标记过程类似标记-清除算法,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,类似于磁盘整理的过程,适用于对象存活率高的场景,比如老年代。

4)分代收集算法:根据对象的存活周期将它们放到不同的区域,并在不同的区域采用不同的收集算法。目前主流的Java虚拟机的垃圾收集器都采用分代收集算法,新生代对象存活率低,就采用复制算法;老年代存活率高,就用标记-清除算法或者标记-整理算法。

十二、垃圾收集器?

1)Serial 收集器:Serial(串行)收集器收集器是最基本、历史最悠久的垃圾收集器,是一个新生代收集器。它是一个单线程收集器,简单而高效(与其他收集器的单线程相比)。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( "Stop The World" ),直到它收集结束。Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择。采用“复制”算法。

2)ParNew 收集器:ParNew 收集器其实就是 Serial 收集器的多线程版本,也是一个新生代收集器。它是许多运行在 Server 模式下的虚拟机的首要选择。采用“复制”算法。

3)Parallel Scavenge 收集器:Parallel Scavenge 收集器几乎和ParNew一样,也是一个新生代收集器。不过它的关注点是吞吐量(高效率的利用 CPU)。采用“复制”算法。

4)Serial Old 收集器:Serial 收集器的老年代版本,是一个老年代收集器。采用“标记-整理”算法。

5)Parallel Old 收集器:Parallel Scavenge 收集器的老年代版本,是一个老年代收集器。采用“标记-整理”算法。

6)CMS 收集器:CMS(Concurrent Mark Sweep)收集器是一个老年代收集器,是一种以获取最短回收停顿时间为目标的收集器,特点是“并发”与“低停顿”。是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。采用“标记-清除”算法。有“标记-清除”算法相应缺点。

7)G1 收集器:G1 收集器是一个新生代&老年代收集器,它采用“化整为零”的思想,将整个Java堆划分为多个大小相等的独立区域(Region),有计划地避免在整个Java堆中进行全区域的垃圾收集,它在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region。主要特点也是“并发”与“低停顿”,另外与CMS 收集器相比,还不会产生内存碎片和每次停顿时间更加短。是一款面向服务端应用的收集器。从整体来看是基于“标记-整理”算法,从局部(两个Region之间)来看是基于“复制”算法。

JavaGuide:JVM垃圾回收
《深入理解java虚拟机》

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值