JVM相关知识点

另可参考: http://www.stevenwash.xin/2018/04/21/JVM%E5%9F%BA%E7%A1%80%E7%9F%A5%E8%AF%86/

一、Java运行时数据区域

程序计数器

看做是当前线程说执行的字节码的行号指示器。由于多线程是通过线程的轮流切换来分配CPU的处理时间的,所以,为了使切换后的线程能正确按照之前的顺序执行,则每个线程都有一个自己的程序计数器,各条线程之间的计数器是独立存储的,所以这个存储空间的线程私有的。

Note:如何正在执行的是Native方法,则计数器的值为空,此内存区域是唯一一个Java中没有规定任何OutOfMemeryError情况的区域。

Java虚拟机栈

同程序计数器一样,是属于线程私有的。虚拟机栈描述的是每个方法运行时的内存模型,通过栈帧来存储局部变量表、操作数栈、动态链接、方法出口等信息。

其中,局部变量表中存放了基本数据类型、对象引用类型(指向堆内存中对象的引用)和returnAddress类型(指向一条字节码地址的指令)

Java堆

是被所有线程共享的内存区域,唯一目的就是存放对象实例,所以也是垃圾回收的主要场所,几乎所有的对象都是在堆上分配。

Special:JIT和逃逸技术的发展,使得可能会发生一些小变化,这就导致所有对象都分配在堆上就不绝对了

内存回收的角度:
分代收集算法,分为新生代和老年代,更细的就是Eden空间、From Survivor空间、To Survivor空间。

内存划分的角度:
还会划分出线程私有的分配缓冲区(TLAB),但是无论如何划分存放的都是对象的实例。

本地方法栈

作用和Java虚拟机栈的作用一样,只是这个是为Java提供Native方法服务的。

方法区

是线程共享的内存区域,存储已被虚拟机加载的类信息、常量、静态变量、即时编译器(JIT)编译后的代码等数据。

在这个区域也有少量的垃圾收集,是针对常量池的回收和类型的卸载。

运行时常量池:在方法区中用来存放常量信息(各种在类加载后放到常量池中的字面量和符号引用)

Special:直接内存

不是属于JAVA运行时数据区的一部分。由于引入的NIO,是一种基于管道的和缓冲区的IO方式,直接分配堆外内存。

二、判断对象是否死了

引用计数的算法

原理:给每一个对象添加一个引用计数器,每当有地方引用了这个对象,计数器就+1,某个地方的引用失效了则计数器-1。任何时刻计数器都为0的对象,就表示已经不可能再被引用了。

缺陷:无法解决循环依赖引用的问题。因为如果两个对象循环引用了,则两个对象的引用计数器永远都不会为0,于是无法回收。

可达性分析算法

通过一系列的成为GC Roots的对象集(Set)来做为起点,从这些节点开始向下搜索,搜索走过的路径称为引用链,当一个对象到GC Roots没有任何一条引用链相连的时候,就证明此对象是不可用的,即可被回收。

可作为GC Roots的对象

1、虚拟机栈中(栈帧中的本地变量表中)引用的对象
2、方法区中静态引用指向的对象
3、方法区中常量引用指向的对象
4、本地方法栈中的Native方法引用的对象

当一个对象被判定为不可达,则要经历两次标记才会清理:
如果这个对象有必要执行的finalize()方法的时候,就会将这个对象放到F-Queue中等待虚拟机执行,但并不会承诺会执行完。如果在finalize方法中想要拯救自己,只需要将自己连上一个类变量或者对象的成员变量,此时就会存活下来。否则,就会真的被回收了。

如果没有必要执行的finalize()方法:两种情况:1、没有覆盖finalize()方法 2、该方法已经被执行过了,此时就会直接回收了。

三、垃圾回收算法(关注的是堆和方法区)

标记-清除算法(最基础的回收算法)

两个步骤:
1、首先标记出所有的要被回收的对象(就是通过可达性分析找到的要被回收的对象)
2、然后在标记完成之后统一回收所有被标记的对象。

不足:
1、标记和清除的两个过程的效率都不高(需要暂停应用)
2、标记清除之后会产生大量不连续的内存碎片

复制算法

解决效率问题。
方法:
1、先将能使用的内存划分为两个相等的块,每次只使用其中的一块
2、当这块使用完了,就将所有存活的对象复制到另一块中,然后把之前这一块的内存一次清理完

优点:每次都是对整个区域回收,也不用考虑内存碎片的问题,实现简单,高效
不足:将内存缩小了一半

实际应用:
由于98%的对象都是很快就会死了,所以可以将内存划分为一块较大的Eden空间和两块较小的Survivor空间,可能回借助分配担保机制进入老年代。

标记-整理算法

步骤:
1、首先将可回收的对象进行标记
2、然后将所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存

分代收集算法

将Java内存分为几个区域,然后针对不同区域进行最适合的方式来收集。
新生代:采用复制算法(因为存活率低)
老年代:采用标记-清楚或者标记整理算法

四、内存分配和回收的策略

GC常发生的是在堆区,主要分为新生代和老年代,其中新生代又可以划分为Eden和两个Survivor区。

对象优先在Eden中进行分配。

当Eden的空间不够的时候就会在Eden区域发生一次Minor GC,因为大多数对象都朝生夕灭,所以Minor GC发生的次数非常的频繁,而且效率高。

大对象直接进入老年代。

大对象是指需要大量连续内存空间的Java对象,比如很长的字符串以及数组等。可以通过参数设置进入老年代的对象的大小值。

再就是在Eden中经过N次回收都没有被回收的,即年龄在N以上的会进入到老年代。

老年代GC(Major GC/Full GC):发生在老年代,一般会伴随至少一次的Minor GC,速度很慢,比Minor GC慢10倍以上。

长期存活的对象进入老年代

对象年龄计数器:
①、在Eden中对象经历过第一次Minor GC之后任然存活在Survivor的对象年龄增加1
②、在Survivor中每熬过一次Minor GC的年龄就会增加1岁

当对象年龄增加到一定的时候(默认是15岁),就会进入老年代,这个年龄阈值可以通过参数设置。

五、内存溢出和内存泄露

内存溢出:内存不够使用
内存泄露:是指申请的内存空间无法释放。

内存泄露累计起来之后就会导致内存溢出。

内存泄露的原因:
1、长生命周期的对象引用了短生命周期对象
2、没有将无用的对象设置为null来提供给GC即时回收

六、类加载器

类与类加载器

对于任意一个类,都需要由加载这个类的类加载器和这个类本身来确立其在JVM中的唯一性。即,即使是同一个class文件,被同意一个虚拟机加载,但是加载它们的类加载器不一样,这样两个类就不一样。

双亲委派模型

3中系统提供的类加载器:启动类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)、应用程序类加载器(Application ClassLoader)

除了顶层的启动类加载器之外,其他的类加载器都有自己的父类加载器(这种父子关系是通过组合实现)。

双亲委派模型:当一个类加载器接收到类的加载请求的时候,不会自己进行加载,而是将加载的任务委派给该类加载器的父类加载器,每一层的类加载器都是如此,所以所有的加载请求最终都会传到顶层的启动类加载器上进行加载。只有当父加载器无法完成加载任务的时候,子加载器才会尝试自己加载。

这就可以使得JAVA的类具有优先层级的关系,比如Object类,在rt.jar中,最终都是有启动类加载器进行加载,这就保证了Object的唯一。

破坏双亲委派模型

第一次破坏:JDK1.2发布之前

第二次破坏:基础类又要回调用户代码。比如JNDI服务,JNDI的类由启动类加载器进行加载,但是在使用JNDI对资源进行集中管理的时候,它有需要回调独立厂商提供的SPI代码,此时的启动类加载器是无法识别这些代码的。

解决方法:引入线程上下文类加载器(ThreadContext ClassLoader),可以通过setContextClassLoader进行设置,默认是应用程序类加载器

第三次破坏:用户对程序动态性的追求导致的,如:代码热替换、模块热部署等。

OSGI(开放服务网关倡议) java模块化标准。类加载器成为了一种复杂的网状结构,而不是双亲委派的树状结构,其中每一个程序模块都有一个自己的类加载器。

七、类加载机制

类加载的过程:加载、验证、准备、解析、初始化、使用、卸载

其中加载、验证、准备、初始化和卸载这五个步骤是顺序执行的,但是解析和使用不一定。

因为JAVA语言支持动态绑定,也就是在某些情况下可以进行完初始化之后再进行解析。

类加载的时机

有五种情况下必须进行类的初始化,因此加载、验证、准备应该要在初始化之前完成。

1、遇到new,getstatic,putstatic,invokestatic这四条字节码指令的时候,如果类还没有进行初始化,则需要先出发初始化操作,对应JAVA中的操作就是:①、使用new关键字实例化对象②、获取或者设置静态字段③、调用一个类的静态方法
2、通过反射的方式对一个类进行反射调用的时候,如果没有初始化则会对这个类进行初始化操作。
3、当初始化一个类的时候,发现其父类还没有被初始化,则会先触发其父类的初始化操作
4、当虚拟机启动的时候,用户需要指定一个主类(即包含了main方法的类),虚拟机会先初始化这个主类
5、在jdk1.7中,使用java.lang.invoke.MethodHandle的时候,将会被解析成REF_getstatic等方法的句柄,并且在这个方法句柄对应的类没有进行初始化,则会触发该类进行初始化。

类加载的过程

即加载、验证、准备、解析和初始化这个五个步骤

加载

1、通过类的全限定名来获取定义此类的二进制流,获取的地方可以来自于:ZIP包、网络、动态代理生成、其他文件、数据库等。
2、将这个字节流的静态存储结构转化为运行时方法区运行时数据结构
3、生成相应的Class对象

验证

主要验证的信息有:
1、文件格式的验证:验证文件是否符合class文件格式的规范
2、元数据验证:验证字节码的描述信息,看是否符合JAVA语义规范
3、字节码验证:最复杂的过程,通过控制流和数据流的分析,判断程序的语义是否符合规范和逻辑
4、符合引用验证:主要发生在解析阶段,对类自身以外的信息进行匹配性验证

准备

为类变量分配内存空间,并且给类变量设置初始值(是指零值),此时的类变量是在方法区中进行分配。

注意:是类变量,不是实例变量,实例变量随着类的实例化将在堆中分配。

解析

一方面是验证符号引用,另一方面将符号引用转化为直接引用。

解析动作主要针对:类或接口的解析、类方法和接口方法的解析、字段解析等。

初始化

在准备阶段,变量已经赋值过一次系统要求的初始值,在初始化阶段就是真正执行用户代码的地方了,即按照类构造器进行初始化。

八、Java内存模型与线程

内存模型

主内存和工作内存

所有的变量(这个变量包含的包括实例、静态字段等,不是JAVA语言中的变量)都存在主内存中,每个线程还有一个自己的工作内存,工作内存中存放的是这个线程所使用到的主内存中的变量的副本拷贝。线程的每次对变量的操作都是只能在工作内存中进行,而不能直接读写主内存中的变量。

对于如何进行主内存和工作内存之间的交互,提供了8条命令进行交互:lock/unlock、read/write、load/store、use/assign

对volatile变量特殊规则

1、保证变量的可见性,也就是一个线程修改了某一个变量的值,这个变量的值对于其他的线程来说是立即可见的。

实现的原理是:在每次使用某个volatile变量之前,先将主内存中该变量的值刷新到当前线程的工作内存中,此时保证的就是使用的这个变量的值就是当前最新的值。然后每次修改完这个变量之后,都会立即将当前线程中的该变量的值刷新回工作内存中,这个就保证了每一次的修改都会立即同步到主内存中。

2、禁止指令重排

指令重排:是指为了提高执行效率,CPU允许将多条指令不按照程序的顺序进行执行,只要处理器能正确处理依赖情况以保证能得出正确的处理结果。(是指在机器级的指令优化)

但是在并发的环境,指令的重新排序可能导致执行的顺序有问题,这个时候使用volatile关键字禁止指令的重排可以避免。

对于long和double类型的变量的特殊规则

允许虚拟机将没有用volatile关键字修饰的64位数据的读写操作划分为两次32位的操作来进行。

先行发生原则

用先行原则来保证两项操作之间的顺序关系,有以下可以直接使用的先序关系:
1、程序的控制流顺序
2、管程锁定顺序:即先lock再unlock
3、volatile变量定义的特殊规则
4、传递规则(A先行发生于B,B先行发生于C,则A先行发生于C)
。。。

Java线程

Java线程实现的方式:使用内核线程实现、使用用户线程实现以及使用用户线程加轻量级进程混合实现

线程的实现

使用内核线程实现

先内核线程完成线程的切换等操作,实现起来比较简单,但是,需要经常在用户态和内核态中来回切换,因此系统调用的代价相对较高。

当然应用程序不会直接去使用内核线程,而是使用内核线程的一种高级接口–轻量级进程,这种轻量级进程和线程之间是一对一的关系。

使用用户线程实现

完全在内核中进行,执行的速度非常快而且低消耗,但是在由于没有系统内核的支持,所有的线程的切换、调度等都需要用户自己实现,实现起来会比较复杂。

这中进程和用户线程之间1:N的关系称为一对多线程模型。

使用用户线程加轻量级进程混合实现

就是将用户线程和内核线程(轻量级进程)混合使用。

模型就是N和用户线程对应上M个内核线程(轻量级进程),这样的一方面可以使用内核提供的线程调度的功能,同时还可以避免频繁进行用户态和内核态的切换。

Java线程调度

主要分为协同调度和抢占式调度。

协同式

线程运行的时间由线程本身控制,线程把自身执行完之后主动通知下一个线程进行执行。

实现简单,但是不稳定,易造成系统的崩溃。

抢占式

为线程设置优先级,优先级高的可以抢占优先级低的线程。

但是注意Java中的优先级需要和操作系统中的优先级对应。

线程状态

5种:创建、可运行、有限等待、无限等待和终止

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章