这个项目是从20年末就立好的 flag,经过几年的学习,回过头再去看很多知识点又有新的理解。所以趁着找实习的准备,结合以前的学习储备,创建一个主要针对应届生和初学者的 Java 开源知识项目,专注 Java 后端面试题 + 解析 + 重点知识详解 + 精选文章的开源项目,希望它能伴随你我一直进步!
说明:此项目我确实有很用心在做,内容全部是我参考了诸多博主(已注明出处),资料,N本书籍,以及结合自己理解,重新绘图,重新组织语言等等所制。个人之力绵薄,或有不足之处,在所难免,但更新/完善会一直进行。大家的每一个 Star 都是对我的鼓励 !希望大家能喜欢。
注:所有涉及图片未使用网络图床,文章等均开源提供给大家。
项目名: Java-Ideal-Interview
Github 地址:
Gitee(码云)地址:
持续更新中,在线阅读将会在后期提供,若认为 Gitee 或 Github 阅读不便,可克隆到本地配合 Typora 等编辑器舒适阅读
若 Github 克隆速度过慢,可选择使用国内 Gitee 仓库
注:此部分在 /docs/java/javase-basis/001-Java基础知识.md 已经提到过。
JVM 又被称作 Java 虚拟机,用来运行 Java 字节码文件(.class
),因为 JVM 对于特定系统(Windows,Linux,macOS)有不同的具体实现,即它屏蔽了具体的操作系统和平台等信息,因此同一字节码文件可以在各种平台中任意运行,且得到同样的结果。
扩展名为 .class
的文件叫做字节码,是程序的一种低级表示,它不面向任何特定的处理器,只面向虚拟机(JVM),在经过虚拟机的处理后,可以使得程序能在多个平台上运行。
Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以 Java 程序运行时比较高效,而且,由于字节码并不专对一种特定的机器,因此,Java程序无须重新编译便可在多种不同的计算机上运行。
为什么一定程度上解决了传统解释型语言执行效率低的问题(参考自思否-scherman ,仅供参考) 首先知道两点,① 因为 Java 字节码是伪机器码,所以会比解析型语言效率高 ② JVM不是解析型语言,是半编译半解析型语言 解析型语言没有编译过程,是直接解析源代码文本的,相当于在执行时进行了一次编译,而 Java 的字节码虽然无法和本地机器码完全一一对应,但可以简单映射到本地机器码,不需要做复杂的语法分析之类的编译处理,当然比纯解析语言快。
过程:编写 -> 编译 -> 解释(这也是 Java编译与解释共存的原因)
首先通过IDE/编辑器编写源代码然后经过 JDK 中的编译器(javac)编译成 Java 字节码文件(.class文件),字节码通过虚拟机执行,虚拟机将每一条要执行的字节码送给解释器,解释器会将其翻译成特定机器上的机器码(及其可执行的二进制机器码)。
定义:类加载器会根据指定class文件的全限定名称,将其加载到JVM内存,转为Class对象。
1.2.1.1 加载
1.2.1.2 链接
1.2.1.3 初始化
static int num = 0
变成了 static int num = 3
,这些工作都会在类构造器 ()
方法中执行。而且虚拟机保证了会先去执行父类 ()
方法 。1.2.1.4 卸载
GC 垃圾回收内存中的无用对象
JVM 中本身提供的类加载器(ClassLoader)主要有三种 ,除了 BootstrapClassLoader 是 C++ 实现以外,其他的类加载器均为 Java实现,而且都继承了 java.lang.ClassLoader
-Xbootclasspath
参数指定的路径中的所有类,都归其负责加载。java.ext.dirs
系统变量所指定的路径下的 jar 包。注:顺序为最底层向上
1.2.3.1 概念
双亲委派模型会要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器,不过这里的父子关系一般不是通过继承来实现的,通常是使用组合关系来复用父加载器的代码
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,他首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载都是如此,因此所有的加载请求都最终应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(也就是它的范围搜索中,也没有找到所需要的类),子加载器才会尝试自己去完成加载。
1.2.3.2 优点
public class Object(){
public static void main(){
......
}
}
1.2.3.3 如果不想使用双亲委派模型怎么办
自定义类加载器,然后重写 loadClass() 方法
Java 程序在被虚拟机执行的时候,内存区域被划分为多个区域,而且尤其在 JDK 1.6 和 JDK 1.8 的版本下,有一些明显的变化,不过主题结构还是差不多的。
整体主要分为两个部分:
注:我们配图以 JDK 1.6 为例,至于发生的变化我们在下面有说明
概念:程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。
1.3.2.1 为什么程序计数器是线程私有的?
答:主要为了线程切换恢复后,能回到自己原先的位置。
Java 虚拟机栈描述的是 Java 方法执行的内存模型,每次方法调用时,都会创建一个栈帧,每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。
大部分情况下,很多人会将 Java 内存笼统的划分为堆和栈(虽然这样划分有些粗糙,但是这也能说明这两者是程序员们最关注的位置),这个栈,其实就是 Java 虚拟机栈,或者说是其中的局部变量表部分。
1.3.3.1 Java 虚拟机栈会出现哪两种错误?
StackOverFlowError
: 如果 Java 虚拟机栈容量不能动态扩展,而此时线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。OutOfMemoryError
:如果 Java 虚拟机栈容量可以动态扩展,当栈扩展的时候,无法申请到足够的内存(Java 虚拟机堆中没有空闲内存,垃圾回收器也没办法提供更多内存)和虚拟机栈所发挥的作用非常相似,其区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。
与虚拟机栈相同,在栈深度溢出,以及栈扩展失败的时候,也会出现 StackOverFlowError
和 OutOfMemoryError
两种错误。
1.3.4.1 虚拟机栈和本地方法栈为什么是私有的?
答:主要为了保证线程中的局部变量不被别的线程访问到
Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
补充:Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)
方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
注意:JDK1.7 开始,到 JDK 8 版本之后方法区(HotSpot 的永久代)被彻底移除了,变成了元空间,元空间使用的是直接内存。
1.3.6.1 永久代是什么
在JD K1.8之前,许多Java程序员都习惯在 hotspot 虚拟机上开发,部署程序,很多人更愿意把方法去称呼为永久代,或者将两者混为一谈,本质上这两者不是等价的,因为仅仅是当时 hotspot 虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得 hotspot 的垃圾收集器能够像管理 Java 堆一样管理这部分内存,省去专门为方法去编写内存管理代码的工作,但是对于其他虚拟机实现是不存在永久代的概念的。
1.3.6.2 永久代为什么被替换成了元空间?
运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表(用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中)
1.3.7.1 方法区的运行时常量池在 JDK 1.6 到 JDK 1.8 的版本中有什么变化?
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。
1.4.1.1 类加载检查
1.4.1.2 为对象分配内存
概念:加载检查和加载后,就是分配内存,对象所需内存的大小在类加载完成后便完全确定(对象的大小 JVM 可以通过Java对象的类元数据获取)为对象分配内存相当于把一块确定大小的内存从Java堆里划分出来。
注:Java 堆是否规整,取决于 GC 收集器的算法是什么,如 “标记-清除” 就是不规整的,“标记-整理(压缩)” 、 “复制算法” 是规整的。这几种算法我们后面都会分别讲解。
1.4.1.3 对象初始化零值
内存分配结束后,执行初始化零值操作,即保证对象不显式初始化零值的情况下,程序也能访问到零值
1.4.1.4 设置对象头
初始化零值后,显式赋值前,需要先对对象头进行一些必要的设置,即设置对象头信息,类元数据的引用,对象的哈希码,对象的 GC 分代年龄等。
1.4.1.5 执行对象 init 方法
此处用来对对象进行显式初始化,即根据程序者的意愿进行初始化,会覆盖掉前面的零值
首先举个例子:Student student = new Student();
假设我们创建了这样一个学生类,Student student 就代表作为一个本地引用,被存储在了 JVM 虚拟机栈的局部变量表中,此处代表一个 reference 类型的数据,而 new Student 作为实例数据存储在了堆中。还保存了对象类型数据(类信息,常量,静态变量)
而我们在使用对象的时候,就是通过栈上的这个 reference 类型的数据来操作对象,它有两种方式访问这个具体对象
句柄方式配图:
直接指针方式配图:
堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。
1.4.3.1 四种引用类型的程度
无论是引用计数算法,还是可达性分析算法,判定对象的存活都与引用有关,但是 JDK 1.2 之间的版本,将对象的引用状态分为 “被引用” 和 “未被引用” 实际上有些狭隘,描述一些食之无味,弃之可惜的对象就有一些无能为力,所以1.2 之后边进行了更细致的划分。
JDK1.2之前,引用的概念就是,引用类型存储的是一块内存的起始地址,代表这是这块内存的一个引用。
JDK1.2以后,细分为强引用、软引用、弱引用、虚引用四种(逐渐变弱)
标记清除算法首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象,也可以反过来。
它的主要缺点有两个:
它属于基础算法,后续的大部分算法,都是在其基础上改进的。
标记复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了就将还存活着的对象复制到另一块上面,然后再把已经使用过的内存空间再次清理掉。
缺点:如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销。****
优点:
标记复制算法在对象存活率较高的时候就要进行较多的复制,操作效率将会降低,更关键的是如果不想浪费50%的空间,就需要有额外的空间进行分配担保以应对呗,使用内存所有对象都百分百存活的极端情况,所以在老年代一般是不采用这种算法的。
标记整理算法与标记清除算法一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存货的对象都向内存空间一端移动,然后直接清理掉边界以外的内存
但移动存活对象也是有缺点的:尤其是在老年代这种每次回收都有大量对象存活的区域,移动存活对象并更新所有引用这些对象的地方,将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用进程才能进行,这种停顿被称为 stop the world。
分代收集理论,首先它建立在两个假说之上:
所以多款常用垃圾收集器的一致设计原则即为:收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(即熬过垃圾收集过程次数)分配到不同的区域之中存储。
很明显的,如果一个区域中大部分的对象都是朝生夕灭,难以熬过垃圾收集过程,那么把它们集中放在一起,每次回收就只需要考虑如何保留少量存活的对象,而不是去标记那些大量要被回收的对象,这样就能以一种比较低的代价回收大量空间,如果剩下的都是难以消亡的对象,就把它们集中到一块,虚拟机便可以使用较低的频率来回收这个区域。
所以,分代收集算法的思想就是根据对象存活周期的不同,将内存分为几块,例如分为新生代(Eden 空间、From Survivor 0、To Survivor 1 )和老年代,然后再各个年代选择合适的垃圾收集算法
新生代中每次都会有大量对象死去,所以选择清除复制算法,要比标记清除更高效,只需要复制移动少量存活下来的对象即可。
老年代中对象存活的几率比较高,所以要选择标记清除或者标记整理算法。
注:此处参考引用博文:为什么新生代内存需要有两个Survivor区 注明出处,请尊重原创
补充:
如果没有Survivor,Eden区每进行一次Minor GC,存活的对象就会被送到老年代。老年代很快被填满,触发Major GC(因为Major GC一般伴随着Minor GC,也可以看做触发了Full GC)。老年代的内存空间远大于新生代,进行一次Full GC消耗的时间比Minor GC长得多。你也许会问,执行时间长有什么坏处?频发的Full GC消耗的时间是非常可观的,这一点会影响大型程序的执行和响应速度,更不要说某些连接会因为超时发生连接错误了。
引用博文的作者观点:设置两个Survivor区最大的好处就是解决了碎片化,刚刚新建的对象在Eden中,经历一次Minor GC,Eden中的存活对象就会被移动到第一块survivor space S0,Eden被清空;等Eden区再满了,就再触发一次Minor GC,Eden和S0中的存活对象又会被复制送入第二块survivor space S1(这个过程非常重要,因为这种复制算法保证了S1中来自S0和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生)。S0和Eden被清空,然后下一轮S0与S1交换角色,如此循环往复。如果对象的复制次数达到16次,该对象就会被送到老年代中。
个人观点,更本质是考虑了效率问题,如果是因为产生了碎片的问题,我完全可以使用标记整理方法解决,我更倾向于理解为整理空间带来的性能消耗是远大于使用两块 survivor 区进行复制移动的消耗的。
注:如果这一块不清楚,可以参考一下引用文章的图片。
#### 1.6.3 哪些对象会直接进入老年代
-XX:MaxTenuringThreshold
来设置。为了能更好的适应不同程序的内存状况,HotSpot 虚拟机并不是永远要求对象年龄必须达到 -XX:MaxTenuringThreshold,才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。
Serial 收集器是最基本、历史最悠久的垃圾收集器了。在 JDK 1.3.1 之前是 HotSpot 虚拟机新生代收集器的唯一选择,大家看名字就知道这个收集器是一个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( "Stop The World" ),直到它收集结束。
对于 "Stop The World" 带给用户的恶劣体验早期 HotSpot 虚拟机的设计者们表示完全理解,但也表示委屈:你妈妈在给你打扫房间的时候,肯定会让你老老实实的在椅子上或者房间外等待,如果她一边打扫你一边乱扔纸屑,这房间还能打扫完吗?这其实是一个合情合理的矛盾,虽然垃圾收集这项工作听起来和打扫房间属于一个工种,但实际上肯定要比打扫房间复杂很多。 虽然从现在看来,这个收集器已经老而无用,弃之可惜,但是它仍然是 HotSpot 虚拟机在客户端模式下默认的新生代收集器,因为其有着优秀的地方,就是简单而又高效,内存消耗也是最小的。
ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、Stop The World、对象分配规则、回收策略等)和 Serial 收集器完全一样。
它除了支持多线程并行收集之外,与 Serial 收集器相比没有太多的创新之处,但却是不少运行在Server 服务端模式下的 HotSpot 虚拟机的选择。
Parallel Scavenge 收集器也是基于标记-复制算法的多线程收集器,看起来和 ParNew 收集器很相似。
Parallel Scavenge 的目标是达到一个可控制的吞吐量(处理器用于运行这个程序的时间和处理器总消耗的时间之比),即高效利用 CPU,同时它也提供了很多参数供用户找到最合适的停顿时间或最大吞吐量。
Serial 收集器的老年代版本,它同样是一个单线程收集器。其主要意义还是提供客户端模式下的 HotSpot 虚拟机使用。
Parallel Scavenge 收集器的老年代版本。也是一个基于 “标记-整理”算法的多线程收集器。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。
CMS(Concurrent Mark Sweep) 收集器是一种以获得最短回收停顿时间为目标的收集器,能给用户带来比较好的交互体验。基于标记清除算法。
G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。同时不会产生碎片。
优点和特点: