博主还处于学习阶段,讲的不对的地方欢迎指出,或者留个好文的传送门
Java虚拟机大致可以分为以下几块内容
类的加载是指将.class文件中的二进制数据读入内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个
java.lang.class
对象,用来封装类在方法区内的数据结构。类加载的最终产品是位于堆区中的Class
对象,Class
对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口。加载class文件的来源:
Java源文件动态编译成.class
zip、jar包等归档文件中(jar是基于zip的一种归档方式)
本地系统加载.class
通过网络下载.class
数据库中提取.class
类的生命周期包括了加载、链接、初始化、使用、卸载五个阶段。其中类的加载过程分为加载、验证、准备、解析、初始化这五个阶段。其中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。
加载:查找并加载类的二进制数据,在Java堆中创建一个java.lang.Class类的对象
链接:链接分三块内容:验证、准备、解析。验证文件格式、元数据、字节码、符号引用;准备是为类的静态变量分配内存,并将其初始化为默认值;解析是把类中的符号引用转换成直接引用
这里简单讲一下直接引用。
如:User user = new User(),user这个变量名能逻辑定位到堆中的实例对象即符号引用,但是在物理存储中,本质上仍然是通过一个地址来定位的,而这个地址或者指向这个地址的“指针”就是直接引用。下文会列举一些直接引用。
初始化:为类的静态变量赋予正确的初始值
使用:new出对象程序中使用
卸载:执行垃圾回收
自下往上依次是启动类加载器、扩展类加载器、应用类加载器、自定义类加载器。
启动类加载器(Bootstrap ClassLoader)
加载java的核心库(jdk/jre/lib目录下如java.开头的类),一般使用C语言实现的,没有继承java.lang.ClassLoader,也无法在java程序中获取该类的实例
扩展类加载器(ExtClassLoader)
父类加载器是Bootstrap ClassLoader,但是getParent()返回null
加载java的扩展库(jdk/jre/lib/ext目录下如javax.*开头的类),开发者可直接使用
系统类加载器(AppClassLoader,或也叫SystemClassLoader)
父类加载器是ExtensionClassLoader
加载用户类路径classpath指定的类,开发者可直接获取类的实例并使用:ClassLoader.getSystemClassLoader()
自定义类加载器
开发人员可通过继承java.lang.ClassLoader
类来实现
这里的父类加载器是通过组合的方式实现的,并没有继承。每个子加载器都是要在父加载器中启动的
流程:
1)继承ClassLoader
2)重写findClass()方法
3)调用defineClass()方法
双亲委派(跟父类委托是同一个意思)机制有什么好处
Java类伴随其类加载器具备了带有优先级的层次关系,确保了在各种加载环境的加载顺序。 保证了运行的安全性,防止不可信类扮演可信任的类。
全盘负责
当一个类加载器负责加载某个Class时,该Class所依赖和引用的其他Class也将默认由该类加载器负责载入。
父类委托
先让父类加载器加载该类,只有在父类加载器无法加载该类时才尝试自己加载
JVM如何判断两个类是否相同,以下两者都要判断
1、比较全限定名。2、通过getClassLoader方法判断两个类指向的类加载器是否一致
缓存机制
保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class。缓存区不存在时,JVM会重新加载该类并创建Class对象,放入缓存区中
计算机中cpu和内存的交互最为频繁,内存是计算机中读写最快的存储容器。但是随着cpu的发展,内存的读写速度也赶不上cpu的计算速度,因此厂家在每个cpu上都加上了高速缓存。这种方法暂时缓解了cpu和内存交互的矛盾,但是引来了新的问题:缓存一致性。主内存只有一个,而多核cpu中高速缓存有n个,如何保证多线程场景下缓存数据不一致的问题呢
- 通过给总线加锁
- 使用缓存一致性协议
第一种方法效率比较低。所以出现了第二种缓存一致性协议,如MESI。协议的核心思想是当CPU写数据时,如果发现操作的变量是共享变量,就发出信号通知其他CPU将它高速缓存中的这个变量置为无效状态。当其他CPU需要这个变量时就会重新去主存中读取
计算机内存模型
当一个变量更新后,这个变量马上更新到主存中,其他线程会收到通知这个变量修改过了,使用这个变量时去主存重新加载到高速缓存中
Java内存模型(JMM)
计算机内存模型是一种解决多线程场景下的一个主存操作规范,Java内存模型就是Java语言对这个操作规范的遵守,屏蔽了各种硬件和操作系统的差异。每个线程都有自己的工作区(线程栈),线程将用到的变量从主存(堆、方法区)中赋值一份到自己的工作区,线程对变量的所有操作必须在工作区,不同的线程也无法访问对方工作区,线程之间的消息传递都需要通过主存来完成。
两者的关联和区别
JMM其实工作在计算机主存中,JMM中的“工作区”、“主存”也是计算机主存中的一部分。可以说:JMM解决的是内存一致性问题(“主存”和主存);而计算机内存模型解决的是缓存一致性问题(CPU高速缓存和主存)。这两个模型类似,但是两者的作用域不同。JMM保证的是主存和主存之间的原子性、可见性、有序性;计算机内存模型保证的是主存和缓存之间的原子性、可见性、有序性。
内存屏障在有些文章中也叫内存栅栏,是硬件和软件中对并发操作做出的最后一层支持,是为了解决硬件层面的可见性与重排序的问题(volatile关键字解决了编译器层面的可见性和重排序问题)
简单科普一下可见性和重排序,首先这两个名词一般都是定义在并发场景中,以多线程为例
PC寄存器和Java栈以及本地方法栈随线程而生,随线程而灭,线程消亡,所有的内存就释放了。而Java堆区和方法区不一样,需要动态回收释放内存
Java堆:存放对象、数组(数组也是一个对象)这类由GC管理的对象,这些对象是无法被显示销毁的。
方法区:存储了每一个类的结构信息,如:运行时常量池,字段和方法数据,方法的字节码内容,JIT编译代码等。方法区是堆的逻辑组成部分,但是可以选择不受GC管理
Java堆和方法区都在虚拟机启动时就被创建,容量可以固定也可以动态扩展,且堆所占的内存在物理上并不需要连续
class文件信息:包括类的版本、字段、方法、接口、常量池等描述信息。(这里的常量池和运行时常量池不同,这个常量池用于存放字节码中用到的所有字面量和引用量,类被加载到内存中后,该常量池中的内容进入方法区的运行时常量池存放)
运行时常量池:存在于方法区中,具有动态性。即除了类加载时将常量池写入其中,Java程序运行期间也可以写入常量:
// 直接使用字符串字面量xyz,其被放入运行时常量池
String str2 = "xyz";
// 使用StringBuilder在堆上创建字符串abc,再使用intern将其放入运行时常量池
String str = new StringBuilder("abc");
str.intern();
JIT代码:JIT是一个即时编译器,以hotspot虚拟机为例,其在运行时会使用JIT即时编译器对热点代码(虚拟机发现某个方法或者代码块运行的特别频繁)进行优化,优化方式为将字节码编译成机器码。
全局字符串常量池、Class文件常量池、运行时常量池的区别
全局字符串常量池(String Pool)
全局字符串池里的内容是在类加载完成。经过验证、准备阶段之后在堆中生成字符串对象实例,然后将该字符串对象实例的引用值存到全局字符串常量池中,(HotSpot虚拟机中实现String Pool的是一个StringTable类,是一个哈希表)整个虚拟机只有一个String Pool。
Class文件常量池(Class Constant Pool)
即上文讲到的class文件中包含的常量池。用于存放编译器生成的各种字面量和符号引用。字面量就如文本字符串、final修饰的常量等;符号引用即对象名一般包括:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。(上文说到过,运行时常量池有动态性,而Class文件常量池则是静态的。)
运行时常量池
类加载完成之后,每个Class文件常量池中的符号引用转换成直接引用存放到运行时常量池中,与全局字符串常量池中的引用值一致,与Class文件常量池对应,故运行时常量池也是每个类对应一个。
扯远一些,方法区就是把每个类的唯一标识作为他的段地址,而内部的各个变量字段方法等都是偏移地址,等到真正入栈执行时候这些字段方法的相对定位符等被解析成为真正的地址(是不是想到了微机中“段+偏移”的定位方式)
Java虚拟机栈:线程创建时产生,方法执行时生成栈帧,用于存储局部变量和操作数栈,每个方法一个栈帧。除了局部变量和操作数栈,还有一个指向类对应的运行时常量池的引用,这个引用是为了方便对当前方法中未解析的动态变量实现动态连接,将符号引用替换成直接引用。栈帧随着方法的调用而创建,随着方法的结束(正常结束、抛出未被捕获的异常)而销毁
本地方法栈:与Java线程栈功能类似,只是本地方法栈是为Native方法服务
程序计数器:较小的内存空间,指示当前线程所执行的字节码的行号
谈到对象的内存分配规则就不能不说一下新生代、老年代、永久代的关系了。
堆中分为新生代和老年代,而永久代事实上是方法区的一种实现方式。《Java虚拟机规范》中值时规定了有方法区这么一个概念,而永久代则是HotSpot对于此规范的实现。再JDK8中移除了永久代的实现方式,用元空间取代。
那么永久代和元空间有什么不同呢?
存储位置不同,永久代物理是是堆的一部分,和新生代,老年代地址是连续的,而元空间属于本地内存(本地内存即表示元空间并不在虚拟机中);存储内容不同,元空间存储类的元信息,静态变量和常量池等并入堆中。相当于永久代的数据被分到了堆和元空间中。
分代垃圾回收机制:不同的对象生命周期不同。把不同生命周期的对象放在不同代上,不同代上采用最合适它的垃圾回收方式进行回收。新生代存放所有新生成的对象;老年代存放在新生代中经历了n次垃圾回收仍然存活的对象,是一些生命周期比较长的对象;永生代中存放静态文件。新生代的GC命名Minor GC,老年代的GC命名Full GC或Major GC。System.gc()执行的是Full GC。
对象优先分配在新生代,当新生代没有足够空间时,虚拟机执行一次Minor GC
大的(占内存)对象直接进入老年代(),避免在新生区和老年区之间发生大量的内存拷贝(新生代采用复制算法收集内存)。常见的大对象就是大数组
byte[] data = new byte[4*1024*1024]
对象的内存分配,往大方向上讲就是在堆上分配,对象主要分配在新生代的Eden Space和From Space,少数情况下会直接分配在老年代。如果新生代的Eden Space和From Space的空间不足,则会发起一次GC,如果进行了GC之后,Eden Space和From Space能够容纳该对象就放在Eden Space和From Space。在GC的过程中,会将Eden Space和From Space中的存活对象移动到To Space,然后将Eden Space和From Space进行清理。如果在清理的过程中,To Space无法足够来存储某个对象,就会将该对象移动到老年代中。在进行了GC之后,使用的便是Eden space和To Space了,下次GC时会将存活对象复制到From Space,如此反复循环。当对象在Survivor区躲过一次GC的话,其对象年龄便会加1,默认情况下,如果对象年龄达到15岁,就会移动到老年代中。
一般通过两种方式判断一个对象是否可回收
引用计数法
java中是通过引用来和对象关联的,如果一个对象没有任何引用,则说明这个对象不太可能在其他地方被使用到,那么这个对象就可以被回收了。
这种方式实现简单效率较高,但是无法解决循环引用问题,因此Java中没有采用这种模式
class MyObject {
public Object object = null;
}
MyObject obj1 = new MyObject();
MyObject obj2 = new MyObject();
obj1.obj = obj2;
obj2.obj = obj1;
// 这里obj1/obj2都赋null,但是在堆中仍然存在两块对象互相引用着,无法被回收
obj1 = null;
obj2 = null;
可达性算法
从GC ROOT节点开始进行搜索,如果GC ROOT和一个对象之间没有可达路径,则标记该对象是不可达的。被判定为不可达的对象必须至少经历两次这样的标记才会被回收。
具体来说,以下三种常见情况下对象会被判定为可回收对象
1)指向某个对象的引用被赋值null或将此引用指向另一个对象
Object obj1 = new Object();
obj1 = null;
Object obj1 = new Object();
Object obj2 = new Object();
obj1 = obj2;
2)局部引用所指的对象
for(...) {
Object obj = new Object();
}
循环每执行完一次,生成的Object对象都会成为可回收对象
3)只有弱引用与对象相关联
WeakReference<String> wr = new WeakReference<String>(new String("world"));
这里bb一下引用的类型
Java中的引用:强引用、软引用、弱引用、虚引用
强引用:GC不会回收具有强引用的对象,所以可能会造成内存泄漏。但是即使内存泄漏也只会抛出异常
User user = new User();//强引用
String str = "hello";//强引用
user = null;//取消强引用
str = null;//取消强引用
软引用:稍弱于强引用,GC不会很快回收有软引用的对象,只有当堆的使用率接近阈值时才会被回收。软引用可以用于实现对内存敏感的高速缓存
Object obj = new Object();
SoftReference sf = new SoftReference(obj);
obj = null;
sf.get();
//sf是对obj的一个软引用,通过sf.get()可以返回这个对象,当对象被标记为回收时返回null。
弱引用:弱引用比软引用更弱,GC只要发现弱引用就会回收它,不管内存受否足够
Object obj = new Object();
WeakReference sf = new WeakReference(obj);
obj = null;
sf.get();
虚引用是最弱的引用类型,有没有对对象的影响几乎一样。虚引用只在对象被销毁时返回一个值。
标记-清除法
这个方法分为两个阶段:标记阶段和清除阶段。这个方法实现比较简单,但是有一个问题:容易产生内存碎片,即碎片化的内存空间。导致之后为大对象分配空间时无法找到足够的空间而提前触发新的一次垃圾收集动作
复制算法
复制算法将可用的内存空间划分成相等的两块,当一块的内存用完了,就将还活着的对象复制到另一块上面,然后再把已使用的内存空间一次性清理掉。
这种算法实现也很简单、运行高效(这种算法的效率跟存活对象的数目有关)且不易产生内存碎片,但是付出了高昂的内存代价,可使用的内存仅为原来的一半。
标记-整理算法
标记完可回收对象后,将存活的对象往一端移动,然后清理剩余的内存。
分代收集算法
根据对象的存活周期将内存分为若干个不同的区域。一般情况下划分为新生代和老年代,老年代每次垃圾回收时只有少量对象需要被回收,新生代每次垃圾回收时有大量对象被回收,根据不同代的特点采取合适的算法。
新生代一般采用复制算法,但是实际中不是按1:1划分新生代空间的,一般是将新生代划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden空间和一块Survivor空间,进行回收时将Eden和Survivor中还存活的对象复制到另一块Survivor空间中,然后清理掉Eden和刚才使用过的Survivor空间。
老年代一般使用标记-整理算法
以下是HotSpot提供的几种垃圾收集器
Serial/Serial Old
Serial/Serial Old收集器是最基本最古老的收集器,它是一个单线程收集器,并且在它进行垃圾收集时,必须暂停所有用户线程。Serial收集器是针对新生代的收集器,采用的是Copying算法,Serial Old收集器是针对老年代的收集器,采用的是Mark-Compact算法。它的优点是实现简单高效,但是缺点是会给用户带来停顿。
ParNew
ParNew收集器是Serial收集器的多线程版本,使用多个线程进行垃圾收集
Parallel Scavenge
Parallel Scavenge收集器是一个新生代的多线程收集器(并行收集器),它在回收期间不需要暂停其他用户线程,其采用的是Copying算法,该收集器与前两个收集器有所不同,它主要是为了达到一个可控的吞吐量。
Parallel Old
Parallel Old是Parallel Scavenge收集器的老年代版本(并行收集器),使用多线程和Mark-Compact算法。
CMS
CMS(Current Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,它是一种并发收集器,采用的是Mark-Sweep算法。
G1
G1收集器是当今收集器技术发展最前沿的成果,它是一款面向服务端应用的收集器,它能充分利用多CPU、多核环境。因此它是一款并行与并发收集器,并且它能建立可预测的停顿时间模型。
这块有缘再补充把,太长了,有需要可以看看纯洁的微笑的文章,我这篇的整体结构也是借鉴微笑的文章
参考文献
https://zhuanlan.zhihu.com/p/25511795
https://www.cnblogs.com/dolphin0520/p/3783345.html
https://www.php.cn/java-article-410259.html
http://tangxman.github.io/2015/07/27/the-difference-of-java-string-pool/
https://blog.csdn.net/u011635492/article/details/81046174
https://www.jianshu.com/p/64240319ed60
其实还有很多,忘记记下来了。。。