Java教程

Java底层知识面试题

本文主要是介绍Java底层知识面试题,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

JVM内存结构
class文件格式
JVM不会理解我们写的Java源文件, 我们必须把Java源文件编译成class文件, 才能被JVM识别, 对于JVM而言,class文件相当于一个接口
class文件是一种8位字节的二进制流文件, 各个数据项按顺序紧密的从前向后排列, 相邻的项之间没有间隙, 这样可以使得class文件非常紧凑, 体积轻巧, 可以被JVM快速的加载至内存, 并且占据较少的内存空间。
class文件中的信息是一项一项排列的, 每项数据都有它的固定长度, 有数据项的不同长度分别用u1, u2, u4, u8表示, 分别表示一种数据项在class文件中占据一个字节, 两个字节, 4个字节和8个字节。 可以把u1, u2, u3, u4看做class文件数据项的“类型” 。
magic:在class文件开头的四个字节, 存放着class文件的魔数, 这个魔数是class文件的标志,他是一个固定的值: 0XCAFEBABE 。 也就是说他是判断一个文件是不是class格式的文件的标准, 如果开头四个字节不是0XCAFEBABE, 那么就说明它不是class文件, 不能被JVM识别。
minor_version 和 major_version:紧接着魔数的四个字节是class文件的此版本号和主版本号。
Java程序编译和运行的过程
参考文档

//MainApp.java
public class MainApp {
public static void main(String[] args) {
Animal animal = new Animal("Puppy");
animal.printName();
}
}
//Animal.java
public class Animal {
public String name;
public Animal(String name) {
this.name = name;
}
public void printName() {
System.out.println("Animal ["+name+"]");
}
}

程序运行的详细步骤:

在编译好java程序得到MainApp.class文件后,在命令行上敲java AppMain。系统就会启动一个jvm进程,jvm进程从classpath路径中找到一个名为AppMain.class的二进制文件,将MainApp的类信息加载到运行时数据区的方法区内,这个过程叫做MainApp类的加载。
然后JVM找到AppMain的主函数入口,开始执行main函数。
main函数的第一条命令是Animal animal = new Animal(“Puppy”);就是让JVM创建一个Animal对象,但是这时候方法区中没有Animal类的信息,所以JVM马上加载Animal类,把Animal类的类型信息放到方法区中。
加载完Animal类之后,Java虚拟机做的第一件事情就是在堆区中为一个新的Animal实例分配内存, 然后调用构造函数初始化Animal实例,这个Animal实例持有着指向方法区的Animal类的类型信息(其中包含有方法表,java动态绑定的底层实现)的引用。
当使用animal.printName()的时候,JVM根据animal引用找到Animal对象,然后根据Animal对象持有的引用定位到方法区中Animal类的类型信息的方法表,获得printName()函数的字节码的地址。
开始运行printName()函数。

 

 

运行时数据区:堆、栈、方法区、直接内存、运行时常量池
程序计数器:线程私有,如果线程正在执行一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令地址,如果线程正在执行一个Native方法,这个计数器值为空(Undefined),此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域
Java虚拟机栈:线程私有,为虚拟机执行Java方法(字节码)服务,如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常,如果虚拟机栈可以动态扩展,如果扩展时无法申请足够的内存,就会抛出OutOfMemoryError异常
本地方法栈:线程私有,为虚拟机使用Native方法服务,如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常,如果虚拟机栈可以动态扩展,如果扩展时无法申请足够的内存,就会抛出OutOfMemoryError异常
Java堆:线程共享,Java虚拟机管理的内存中最大的一块,仅用于存放对象实例,Java堆是垃圾收集器管理的主要区域,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。Java堆既可以实现成固定大小的,也可以是可扩展的(通过X-Xmx和Xms控制)
方法区:线程共享,用于存储被虚拟机加载的类信息、常量、静态常量,即编译器编译后的代码等数据。当方法区无法申请到内存时,抛出OutOfMemoryError异常
运行常量池:运行常量池是方法区的一部分.,常量池用于存放编译期生成的各种字面量和符号引用,一般来说,还会把翻译出来的直接引用也存储在运行时常量池,当常量池无法申请到内存时会抛出OutOfMemoryError异常
直接内存:不属于虚拟机运行时数据区的一部分,内存直接分配不受Java堆大小的限制,但是受本机总内存大小和处理器寻址空间的限制,动态扩展时可能出现OutOfMemoryError异常
堆和栈区别
栈内存用来存储局部变量和方法调用。,而堆内存用来存储Java中的对象。无论是成员变量,局部变量,还是类变量,它们指向的对象都存储在堆内存中。
栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存。而堆内存中的对象对所有线程可见。堆内存中的对象可以被所有线程访问。
如果栈内存没有可用的空间存储方法调用和局部变量,JVM会抛出java.lang.StackOverFlowError。而如果是堆内存没有可用的空间存储生成的对象,JVM会抛出java.lang.OutOfMemoryError。
栈的内存要远远小于堆内存,如果你使用递归的话,那么你的栈很快就会充满。如果递归没有及时跳出,很可能发生StackOverFlowError问题。可以通过-Xss选项设置栈内存的大小。-Xms选项可以设置堆的开始时的大小,-Xmx选项可以设置堆的最大值。
Java中的对象一定在堆上分配吗?
随着JIT编译器的发展,在编译期间,如果JIT经过逃逸分析,发现有些对象没有逃逸出方法,那么有可能堆内存分配会被优化成栈内存分配。但是这并不是绝对的。
JVM在内存新生代Eden Space中开辟了一小块区域,由线程私有,称作TLAB(Thread-local allocation buffer),默认设定为占用Eden Space的1%。在Java程序中很多对象都是小对象且用过即丢,它们不存在线程共享也适合被快速GC,所以对于小对象通常JVM会优先分配在TLAB上,并且TLAB上的分配由于是线程私有所以没有锁开销。因此在实践中分配多个小对象的效率通常比分配一个大对象的效率要高。
虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定;通常默认的TLAB区域大小是Eden区域的1%,当然也可以手工进行调整,对应的JVM参数是-XX:TLABWasteTargetPercent。

 

 

什么情况下会发生栈内存溢出?
是否有递归调用
是否有大量循环或死循环
全局变量是否过多
数组、List、map数据是否过大
使用DDMS工具进行查找大概出现栈溢出的位置
什么情况下会发生堆内存溢出?
是否App中的类中和引用变量过多使用了Static修饰
是否App中使用了大量的递归或无限递归(递归中用到了大量的建新的对象)
是否App中使用了大量循环或死循环(循环中用到了大量的新建的对象)
检查App中是否使用了向数据库查询所有记录的方法。即一次性全部查询的方法,如果数据量超过一定数量,就可能会造成内存溢出。所以在查询时应采用“分页查询”。
检查是否有数组,List,Map中存放的是对象的引用而不是对象,因为这些引用会让对应的对象不能被释放。会大量存储在内存中。
检查是否使用了“非字面量字符串进行+”的操作。因为String类的内容是不可变的,每次运行"+"就会产生新的对象,如果过多会造成新String对象过多,从而导致JVM没有及时回收而出现内存溢出。
Java内存模型
参考文档
参考文档

什么是Java内存模型?
Java内存模型即Java Memory Model,简称JMM。JMM定义了Java虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。
Java线程之间的通信采用的是过共享内存模型,这里提到的共享内存模型指的就是Java内存模型(简称JMM),JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。

 

 

JVM对Java内存模型的实现

在JVM内部,Java内存模型把内存分成了两部分:线程栈区和堆区

 

 

JVM中运行的每个线程都拥有自己的线程栈,线程栈包含了当前线程执行的方法调用相关信息,线程栈还包含了当前方法的所有本地变量信息。一个线程只能读取自己的线程栈,也就是说,线程中的本地变量对其它线程是不可见的。即使两个线程执行的是同一段代码,它们也会各自在自己的线程栈中创建本地变量,因此,每个线程中的本地变量都会有自己的版本。
所有原始类型(boolean,byte,short,char,int,long,float,double)的本地变量都直接保存在线程栈当中,对于它们的值各个线程之间都是独立的。对于原始类型的本地变量,一个线程可以传递一个副本给另一个线程,当它们之间是无法共享的。
堆区包含了Java应用创建的所有对象信息,不管对象是哪个线程创建的,其中的对象包括原始类型的封装类(如Byte、Integer、Long等等)。不管对象是属于一个成员变量还是方法中的本地变量,它都会被存储在堆区。
堆中的对象可以被多线程共享。如果一个线程获得一个对象的应用,它便可访问这个对象的成员变量。如果两个线程同时调用了同一个对象的同一个方法,那么这两个线程便可同时访问这个对象的成员变量,但是对于本地变量,每个线程都会拷贝一份到自己的线程栈中。
Java内存模型和硬件架构之间的桥接
Java内存模型和硬件内存架构并不一致。硬件内存架构中并没有区分栈和堆,从硬件上看,不管是栈还是堆,大部分数据都会存到主存中,当然一部分栈和堆的数据也有可能会存到CPU寄存器中

 

 

缓存一致性问题
当对象和变量存储到计算机的各个内存区域时,必然会面临一些问题,其中最主要的两个问题是:

共享对象对各个线程的可见性:当多个线程同时操作同一个共享对象时,如果没有合理的使用volatile和synchronization关键字,一个线程对共享对象的更新有可能导致其它线程不可见。
共享对象的竞争现象:如果多个线程共享一个对象,如果它们同时修改这个共享对象,这就产生了竞争现象。
支撑Java内存模型的基础原理
指令重排序
在执行程序时,为了提高性能,编译器和处理器会对指令做重排序。但是,JMM确保在不同的编译器和不同的处理器平台之上,通过插入特定类型的Memory Barrier来禁止特定类型的编译器重排序和处理器重排序,为上层提供一致的内存可见性保证。

编译器优化重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
指令级并行的重排序:如果不存l在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
内存系统的重排序:处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

数据依赖性
如果两个操作访问同一个变量,其中一个为写操作,此时这两个操作之间存在数据依赖性。
编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序,即不会重排序。

as-if-serial
不管怎么重排序,单线程下的执行结果不能被改变,编译器、runtime和处理器都必须遵守as-if-serial语义。

内存屏障(Memory Barrier )
通过内存屏障可以禁止特定类型处理器的重排序,从而让程序按我们预想的流程去执行。内存屏障,又称内存栅栏,是一个CPU指令,基本上它是一条这样的指令:

保证特定操作的执行顺序。
影响某些数据(或则是某条指令的执行结果)的内存可见性。
编译器和CPU能够重排序指令,保证最终相同的结果,尝试优化性能。插入一条Memory Barrier会告诉编译器和CPU:不管什么指令都不能和这条Memory Barrier指令重排序。
Memory Barrier所做的另外一件事是强制刷出各种CPU cache,如一个Write-Barrier(写入屏障)将刷出所有在Barrier之前写入 cache 的数据,因此,任何CPU上的线程都能读取到这些数据的最新版本。

java内存模型中讲到的volatile是基于Memory Barrier实现的。如果一个变量是volatile修饰的,JMM会在写入这个字段之后插进一个Write-Barrier指令,并在读这个字段之前插入一个Read-Barrier指令。这意味着,如果写入一个volatile变量,就可以保证:

一个线程写入变量a后,任何线程访问该变量都会拿到最新值。
在写入变量a之前的写入操作,其更新的数据对于其他线程也是可见的。因为Memory Barrier会刷出cache中的所有先前的写入。
happens-before
从jdk5开始,java使用新的JSR-133内存模型,基于happens-before的概念来阐述操作之间的内存可见性。

在JMM中,如果一个操作的执行结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系,这个的两个操作既可以在同一个线程,也可以在不同的两个线程中。

与程序员密切相关的happens-before规则如下:

程序顺序规则:一个线程中的每个操作,happens-before于该线程中任意的后续操作。
监视器锁规则:对一个锁的解锁操作,happens-before于随后对这个锁的加锁操作。
volatile域规则:对一个volatile域的写操作,happens-before于任意线程后续对这个volatile域的读。
传递性规则:如果 A happens-before B,且 B happens-before C,那么A happens-before C。
注意:两个操作之间具有happens-before关系,并不意味前一个操作必须要在后一个操作之前执行!仅仅要求前一个操作的执行结果,对于后一个操作是可见的,且前一个操作按顺序排在后一个操作之前。
垃圾回收
参考文档

如何判断一个对象是垃圾?
首先,在Java中,没有任何引用的对象就是一个垃圾。

用什么方法来判断这个对象是垃圾?
引用计数算法
可达性分析算法。

什么是引用计算算法?
通过判断对象的引用数量,来决定对象是否可以被回收。
每个对象实例都有一个引用计数器,如果对象被引用,引用计数器加1,引用完成,则-1.
任何引用计算为零的对象实例都可以当做垃圾被回收。

引用计数算法的优缺点是什么?
优点: 执行效率高,程序执行受影响小。
缺点: 无法检测出循环引用的情况,导致内存泄漏。

什么是可达性分析算法?
通过判断对象的引用链是否可达来决定对象是否可以被回收
可达性分析算法是从离散数学中的图论引入的,程序把所有的引用关系看成是一张图,通过一系列名为GC Root 作为起始点,从这些节点开始向下搜索,搜索所走过的路径就被称为引用链,(也就是:Reference Chain ),当一个对象从GC root 开始 没有任何引用链相连,从图论上说,就是从GC Root 到这个对象是不可达的,那这时就证明了这个对象是不可用的。它也就被标记为垃圾了。
具体执行过程: 垃圾回收器会对整个对象图进行遍历,它从GC root 开始,一直查找其他对象,垃圾回收器会对所有遍历到的对象,标记为存活,GC root 无法到达的对象,就被标记为不可达对象。也就被标记为垃圾。
哪些对象可以被当做GC Root ?
虚拟机栈中引用的对象,(栈帧中的本地变量表)
方法区中的常量引用的对象。
方法区中类静态属性引用的对象。
本地方法栈中JNI(Native方法)的引用对象。
活跃线程的引用对象。
垃圾回收算法
标记清除算法
复制算法
标记整理算法
分代收集算法
什么是标记清除算法?
标记: 从根集合进行扫描,对存活的对象进行标记。
清除: 对堆内存从头到尾进行线性遍历,回收不可达对象内存。
缺点:会造成很多内存碎片。因为标记清除算法。不需要进行对象的移动,并且仅对不存活的对象进行处理,因此标记清除以后,会造成大量的不连续的内存碎片,空间碎片太多,可能会导致,在以后需要创建较大对象时候,无法找到足够的连续内存,而不得不提前触发另一次垃圾回收,

什么是复制算法?
适合对象存活率低的场景,年轻代很多垃圾收集器,都用这个算法回收。
复制算法将内存按容量按一定的比例分为对象面和空闲面。对象在对象面上创建。存活的对象被从对象面复制到空闲面。(这个时候空闲面变为对象面)
将对象面中所有的对象全部清空。 (这个时候,对象面变为空闲面)。
优点:

解决了内存碎片化的问题
顺序分配内存,简单高效
适用于对象存活率低的场景。
缺点:
对于对象存活率高的场景不适合

什么是标记-整理算法?
标记: 从根集合进行扫描,对存活的对象进行标记。
清除: 移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。
优点:

避免了内存的不连续性
不用设置两块内存互换
适用于存活率高的场景
什么是分代收集算法?
按照对象生命周期的不同划分区域以 采用不同的垃圾回收算法,可以提高JVM的回收效率。
JDK7 及以前垃圾回收区分为三部分: Eden Space(年轻代),Tenured Space(老年代),Permanent Space(永久代);
JDK8 : 垃圾回收区分为两部分: Eden Space, Tenured Space , 没有 Permanent Space
GC有哪些分类?
Minor GC: 用来处理年轻代区域
Full GC: 用来收集老年代区域
年轻代的特点?
年轻代: 尽可能的快速收集掉 那些生命周期短的对象。
年轻代(Young)和 老年代(Old) 将堆的空间划分为 1 : 2. 总共三份,
年轻代: 将空间划分为 一个Eden区,两个Survivor区。 比例: 8:1:1.
什么样的对象会晋升到老年代?
经历一定的Minor次数依然存活的对象
Survivor区中存放不下的对象
新生成的大对象
常用的调优参数有哪些?
-XX : SurvivorRatio: Eden和Survivor 的比例,默认 8:1
-XX : NewRatio: 老年代和年轻代的内存的大小比例。 默认是2:1
-XX: MaxTenURingThreshold: 对象从年轻代晋升到老生代经过GC次数的最大阈值。
-XX:MaxTenuringThreshold: 设置Minor GC的最大次数

什么是老年代?
存放生命周期较长的对象。
老年代: 使用 标记-清理算法 和 标记-整理算法 清理垃圾回收。
老年代垃圾回收的时候, 发生了什么?
当触发老年代垃圾回收的时候,会伴随着新生代的垃圾回收,老年代的垃圾回收。
这里经常发生的是Full GC 和 Major GC 。(这两个GC 是等价的)。 用来收集整个GC 堆。
Full GC 比Minor GC 慢的多,大概 10倍, 执行频率低。

什么时候会触发Full GC?
老年代空间不足的时候
永久代空间不足的时候.(这里只指JDK7 及以前,从JDK8 开始没有 永久区,JDK8 用元空间取代了永久代,以减少发生 Full GC 的频率)
CMS GC 时出现 Promotion failed ,concurrent mode failure
Minor GC 晋升到 老年代的平均大小大于老年代的剩余空间
调用 System.gc()
使用 RMI 来进行RPC 或者 管理JDK应用,每小时执行一次 Full GC。1 小时进行一次垃圾收集。
什么是 Stop-the-World?
所谓的Stop the World机制,简称STW,即在执行垃圾收集算法时,Java应用程序的其他所有除了垃圾收集收集器线程之外的线程都被挂起。此时,系统只能允许GC线程进行运行,其他线程则会全部暂停,等待GC线程执行完毕后才能再次运行,对于实时性要求很高的程序来说是难以接受的。
垃圾收集器里面 Safepoint(安全点) 是什么?
可达性分析过程中对象引用不会发生变化的点。 在可达性分析中,分析哪个对象没有引用的时候,必须在一个快照的情况下进行,在这个点,所有的线程都被冻结了,不可以出现,分析过程中,对象引用还在变化的情况,因此分析结果在某个节点,具备确定性,该节点被叫做安全点。(该节点不是随便哪个点就停下来,而是到达安全点,就停顿下来。)一旦GC 发生,就让所有的线程都跑到最新的安全点,再停顿下来,如果发现线程不在安全点,就恢复线程,等其跑到安全点,再停下来。
补充: 安全点不能选择太少,这样会让GC 等待太长的时间。也不能太多,会让程序增加运行的负荷。

什么地方会产生 Safepoint (安全点)?方法调用,循环跳转,异常跳转等。

常见的垃圾收集器有哪些?
年轻代: Serial,ParNew ,Parallel Scavenge.
老年代: CMS, Serial Old(MSC), Parallel Old。

Serial 收集器: (-XX: + UseSerialGC ,复制算法)。
单线程收集,进行垃圾收集时,必须暂停所有工作线程。(适合于用户交互比较多的场景。专注 用户线程停顿时间)
简单高效,Client模式下默认的年轻代收集器。

ParNew收集器: (-XX: + UseParNewGC : 复制算法),只有它可以和CMS收集器配合工作。
多线程收集,其余的行为,特点,和Serial收集器一样。(适合于用户交互比较多的场景。专注 用户线程停顿时间)
单核执行效率不如Serial ,在多核下执行才有优势。

Parallel Scavenge 收集器(-XX: + UseParallelGC ,复制算法)。
比起关注用户线程停顿时间,更关注系统的吞吐量。(适合不需要与用户过多交互的场景)
在多核下执行才有优势,Server模式下默认的年轻代收集器。
补充: 什么是吞吐量?
吞吐量 = 运行用户代码时间 / (运行用户代码时间+ 垃圾收集时间 )

Serial Old 收集器(-XX : UseSerialOldGC , 标记-整理算法):
单线程收集,进行垃圾收集时,必须暂停所有工作线程。
简单高效,Client模式下默认的老年代收集器。

Parallel Old 收集器 (-XX : UseParallelOldGC, 标记整理算法)
多线程,吞吐量优先。

CMS 收集器:(-XX: + UseConcMarkSweepGC, 标记-清除算法)
CMS的优点:可以做到垃圾回收线程几乎能与用户线程做到同时工作。如果你的应用程序,对停顿比较敏感,并且在应用程序运行的时候可以提供更大的内存和更多的CPU,那么使用CMS 会有更大的好处。还有在JVM中,还有相对较多存活时间较长的对象,会更适合使用CMS。
CMS收集器垃圾回收过程是什么,用的什么垃圾回收算法(标记清除算法)?
初始标记: Stop-the-world.(这个时候需要虚拟机停止正在执行的任务),这个过程从垃圾回收的根对象开始,直接扫描到能够和根对象直接关联的对象,并进行标记。
并发标记: 并发追溯标记,程序不会停顿。(并发标记紧随初始标记阶段,并发标记阶段,并发标记线程和应用程序线程并发执行,所以用户不会感受到停顿)。
并发预清理: 查找执行并发标记阶段,从年轻代晋升到老年代的对象。
重新标记: 暂停虚拟机,扫描CMS堆中的剩余对象。(从根对象开始,向下扫描)
并发清理: 清理垃圾对象,程序不会停顿。(这个阶段: 垃圾收集线程和应用线程并发执行)。
并发重置阶段: 重置CMS收集器的数据结构。

什么是 G1 (Garbage First)收集器?
G1 收集器(-XX: + UseG1GC ,复制 + 标记- 整理算法.
特点:
并行和并发(使用多个CPU来缩短stop-the-world 的停顿时间,与用户线程并发执行)
分代收集(G1 独立管理整个堆,但是能够采用不同的方式去处理新创建的对象和已经存活了一段时间熬过了多次GC清理的旧对象,这样可以获得更好的收集效果)。
空间整合(标记-整理算法: 解决内存碎片的问题)
可预测的停顿。(G1 能够建立可预测停顿的时间模型,能让使用者,明确指定在一个长度n 毫秒的时间片段内,消耗在垃圾收集时间不得超过n 毫秒。)

G1 收集器对空间的划分是怎样的?
将整个Java堆内存划分成多个大小相等的独立区域Region 。
年轻代和老年代不再物理隔离。(它们可以是一部分不是物理空间连续的内存了,这个时间再分配内存时,就不需要一段连续的内存,即不用在JVM中指定哪些属于年轻代,哪些属于老年代。因为年轻代被回收以后,就变成可用的Region ,也可以被分配为老年代。)
G1 收集器是 并行stop-the-world收集器, 当一个年轻代GC 发生垃圾回收时,整个年轻代会被回收,G1的老年代回收器有所不同于其他收集器的老年代,G1 的老年代不需要整个老年代进行回收,只有一部分Region被调用,G1 GC 年轻代由 Eden和Survivor 组成,当一个JVM分配一个Eden Region 失败后,就会触发一个年轻代回收,意味着Eden区间满了,然后GC 开始释放空间,第一个年轻代收集器,会移动所有的存储对象,从Eden Region 到 Survivor Region,这就是 copy Survivor的过程。

Java中的强引用,软引用,弱引用,虚引用有什么用?
强引用
之前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。比如下面这段代码中的object和str都是强引用

Object object = new Object();
String str = "StrongReference";
1
2
软引用:
对象处在有用但是非必须的状态。
只有当内存空间不足时,GC会回收该引用的对象的内存。
可以用来实现高速缓存。

String str = new String("abc"); // 强引用。
SoftReference<String> softRef = new SoftReference<String> (str); // 软引用
1
2
弱引用:
非必须的对象,比软引用更弱一些。
GC 时会被回收。
被回收的概率也不大,因为GC线程的优先级比较低。
适用于引用偶尔被使用且不影响垃圾收集的对象。

虚引用:
不会决定对象的生命周期。
任何时候都可能被垃圾收集器回收。
跟踪对象被垃圾收集器回收的活动,起哨兵的作用。(垃圾回收要开始的时候,会先收集这类对象)。
必须和引用队列Reference Queue 联合使用。

引用队列是干嘛的?
引用队列,没有实际的存储结构,存储逻辑依赖于内部节点之间的关系来表达。
存储与引用队列关联的并且被GC 的软引用,弱引用,以及虚引用。

Java对象模型
oop-klass
OOP
在 Java 程序运行的过程中,每创建一个新的对象,在 JVM 内部就会相应地创建一个对应类型的 oop(普通对象指针) 对象。各种 oop 类的共同基类为 oopDesc 类。

在 JVM 内部,一个 Java 对象在内存中的布局可以连续分成两部分:对象头(instanceOopDesc) 和实例数据(成员变量)。

instanceOopDesc 对象头包含两部分信息:Mark Word 和 元数据指针(Klass*):

1.Mark Word:它用于存储对象的运行时记录信息,如哈希值、GC 分代年龄(Age)、锁状态标志(偏向锁、轻量级锁、重量级锁)、线程持有的锁、偏向线程 ID、偏向时间戳等。
2.元数据指针:instanceOopDesc 中的 _metadata 成员,它是联合体,可以表示未压缩的 Klass 指针(_klass)和压缩的 Klass 指针。对应的 klass 指针指向一个存储类的元数据的 Klass 对象。

3.Klass
每个Java对象的对象头里,_klass字段会指向一个VM内部用来记录类的元数据用的InstanceKlass对象;InsanceKlass里有个_java_mirror字段,指向该类所对应的Java镜像——java.lang.Class实例。HotSpot VM会给Class对象注入一个隐藏字段“klass”,用于指回到其对应的InstanceKlass对象。这样,klass与mirror之间就有双向引用,可以来回导航。
这个模型里,java.lang.Class实例并不负责记录真正的类元数据,而只是对VM内部的InstanceKlass对象的一个包装供Java的反射访问用。
在JDK 6及之前的HotSpot VM里,静态字段依附在InstanceKlass对象的末尾;而在JDK 7开始的HotSpot VM里,静态字段依附在java.lang.Class对象的末尾。

类加载机制
参考文档

什么是类的加载
类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。类的加载的最终产品是位于堆区中的Class对象,Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口。

类加载过程
其中类加载的过程包括了加载、验证、准备、解析、初始化五个阶段。在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。

加载
查找并加载类的二进制数据:

通过一个类的全限定名来获取其定义的二进制字节流。
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。
开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。加载阶段完成后,虚拟机外部的 二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在Java堆中也创建一个java.lang.Class类的对象,这样便可以通过该对象访问方法区中的这些数据。

连接
验证:确保被加载的类的正确性,确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致会完成4个阶段的检验动作:

文件格式验证:验证字节流是否符合Class文件格式的规范;例如:是否以0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。
元数据验证:对字节码描述的信息进行语义分析(注意:对比javac编译阶段的语义分析),以保证其描述的信息符合Java语言规范的要求;例如:这个类是否有父类,除了java.lang.Object之外。
字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
符号引用验证:确保解析动作能正确执行。
验证阶段不是必须的,可以考虑采用-Xverifynone参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

准备:为类的静态变量分配内存,并将其初始化为默认值,准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。

这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。
这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。显式地赋予在初始化阶段才会执行。
如果类字段的字段属性表中存在ConstantValue属性,即同时被final和static修饰,那么在准备阶段变量value就会被初始化为ConstValue属性所指定的值。
解析:把类中的符号引用转换为直接引用,解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。符号引用就是一组符号来描述目标,可以是任何字面量。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

初始化
初始化,为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。在Java中对类变量进行初始值设定有两种方式:

①声明类变量是指定初始值

②使用静态代码块为类变量指定初始值

JVM初始化步骤

假如这个类还没有被加载和连接,则程序先加载并连接该类
假如该类的直接父类还没有被初始化,则先初始化其直接父类
假如类中有初始化语句,则系统依次执行这些初始化语句
类初始化时机:只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下六种:

创建类的实例,也就是new的方式
访问某个类或接口的静态变量,或者对该静态变量赋值
调用类的静态方法
反射(如Class.forName(“com.shengsiyuan.Test”))
初始化某个类的子类,则其父类也会被初始化
Java虚拟机启动时被标明为启动类的类(Java Test),直接使用java.exe命令来运行某个主类
结束生命周期
在如下几种情况下,Java虚拟机将结束生命周期

执行了System.exit()方法
程序正常执行结束
程序在执行过程中遇到了异常或错误而异常终止
由于操作系统出现错误而导致Java虚拟机进程终止
双亲委派

 

 

父类加载器并不是通过继承关系来实现的,而是采用组合实现的。Java开发人员的角度来看,类加载器可以大致划分为以下三类:

启动类加载器:Bootstrap ClassLoader,负责加载存放在JDK\jre\lib(JDK代表JDK的安装目录,下同)下,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库(如rt.jar,所有的java.*开头的类均被Bootstrap ClassLoader加载)。启动类加载器是无法被Java程序直接引用的。
扩展类加载器:Extension ClassLoader,该加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载DK\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器。
应用程序类加载器:Application
ClassLoader,该类加载器由sun.misc.Launcher$AppClassLoader来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
应用程序都是由这三种类加载器互相配合进行加载的,如果有必要,我们还可以加入自定义的类加载器。因为JVM自带的ClassLoader只是懂得从本地文件系统加载标准的java class文件,因此如果编写了自己的ClassLoader,便可以做到如下几点:

在执行非置信代码之前,自动验证数字签名。
动态地创建符合用户特定需要的定制化构建类。
从特定的场所取得java class,例如数据库中和网络中。
JVM类加载机制
全盘负责,当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入
父类委托,先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类
缓存机制,缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效
类的加载
类加载有三种方式:

命令行启动应用时候由JVM初始化加载
通过Class.forName()方法动态加载
通过ClassLoader.loadClass()方法动态加载
双亲委派模型
双亲委派模型的工作流程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。

双亲委派机制:

当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。
当ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。
如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载;
若ExtClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException。
双亲委派模型意义:

系统类防止内存中出现多份同样的字节码
保证Java程序安全稳定运行
自定义类加载器
通常情况下,我们都是直接使用系统类加载器。但是,有的时候,我们也需要自定义类加载器。比如应用是通过网络来传输 Java 类的字节码,为保证安全性,这些字节码经过了加密处理,这时系统类加载器就无法对其进行加载,这样则需要自定义类加载器来实现。自定义类加载器一般都是继承自 ClassLoader 类,从上面对 loadClass 方法来分析来看,我们只需要重写 findClass 方法即可。

破坏双亲委派
模块化
类的实例化顺序
父类静态成员和静态初始化块 ,按在代码中出现的顺序依次执行
子类静态成员和静态初始化块 ,按在代码中出现的顺序依次执行
父类实例成员和实例初始化块 ,按在代码中出现的顺序依次执行
父类构造方法
子类实例成员和实例初始化块,按在代码中出现的顺序依次执行
子类构造方法
结论:对象初始化的顺序,先静态方法,再构造方法,每个又是先父类后子类。

编译与反编译
参考文档

什么是编译?
利用编译程序从源语言编写的源程序产生目标程序的过程
用编译程序产生目标程序的动作。 编译就是把高级语言变成计算机可以识别的2进制语言,计算机只认识1和0,编译程序把人们熟悉的语言换成2进制的。 编译程序把一个源程序翻译成目标程序的工作过程分为五个阶段:词法分析;语法分析;语义检查和中间代码生成;代码优化;目标代码生成。
javac是用来编译Java类的,就是将我们写好的.java文件编译成.class文件。
什么是反编译?
计算机软件反向工程也称为计算机软件还原工程,是指通过对他人软件的目标程序(可执行程序)进行“逆向分析、研究”工作,以推导出他人的软件产品所使用的思路、原理、结构、算法、处理过程、运行方法等设计要素,某些特定情况下可能推导出源代码。反编译作为自己开发软件时的参考,或者直接用于自己的软件产品中。
反编译工具:javap 、jad 、CRF
什么是JIT
JIT:Just In TimeCompiler,一般翻译为即时编译器,这是是针对解释型语言而言的,而且并非虚拟机必须,是一种优化手段,Java的商用虚拟机HotSpot就有这种技术手段,Java虚拟机标准对JIT的存在没有作出任何规范,所以这是虚拟机实现的自定义优化技术。
HotSpot虚拟机的执行引擎在执行Java代码是可以采用【解释执行】和【编译执行】两种方式的,如果采用的是编译执行方式,那么就会使用到JIT,而解释执行就不会使用到JIT
HotSpot中的编译器是javac,他的工作是将源代码编译成字节码,这部分工作是完全独立的,完全不需要运行时参与,所以Java程序的编译是半独立的实现。有了字节码,就有解释器来进行解释执行,这是早期虚拟机的工作流程,后来,虚拟机会将执行频率高的方法或语句块通过JIT编译成本地机器码,提高了代码执行的效率
什么是逃逸分析
参考文档

逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术。这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。
public static StringBuffer craeteStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}
1
2
3
4
5
6
StringBuffer sb是一个方法内部变量,上述代码中直接将sb返回,这样这个StringBuffer有可能被其他方法所改变,这样它的作用域就不只是在方法内部,虽然它是一个局部变量,称其逃逸到了方法外部。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。

public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}

不直接返回 StringBuffer,那么StringBuffer将不会逃逸出方法。

使用逃逸分析,编译器可以对代码做如下优化:

同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
在Java代码运行时,通过JVM参数可指定是否开启逃逸分析, -XX:+DoEscapeAnalysis 从jdk 1.7开始已经默认

 

这篇关于Java底层知识面试题的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!