JVM
Java内存模型
内存分配模型
当一个Class文件通过类加载器加载到虚拟机后,就是存放到运行时数据区,具体的说应该是方法区中,JVM内存区域主要分为
- 虚拟机栈:是线程私有的,方法的执行会创建栈帧进行压入,栈帧是用来保存方法的局部变量、返回值、操作数、常量池指针、动态连接;栈的存储速度仅次于寄存器,但是栈中数据是在编译阶段决定的
- 本地方法栈:用来支持native方法执行的栈
- 堆:线程共享,用来存放对象和数组,运行期动态分配内存,GC主要管理的就是堆空间,但是效率相对较慢
- 方法区:存放类加载之后,类结构信息、运行时常量池(类加载到JVM后就创建相应的运行时常量池)JVM规范方法区只是逻辑部分,为了和堆区分开,也叫非堆
- 程序计数器:是线程私有的,指向将要执行的指令地址,若执行的是本地方法,程序计数器的值为undefined
内存交互模型
内存交互模型就是一个操作协议,就是对特定的内存进行访问的过程,简单的说就是,怎么将变量存入内存、或怎么将变量从内存中取出来,这样的操作细节,Java规定
- 共享变量都在主内存,每个线程都有各自的工作内存,工作内存中使用到的变量,其实是从主内存副本
- 线程对变量的读写操作,只能在各自的工作内存,不同线程之间的交互只能通过主内存
Java内存交互模型规定了一些操作,来实现内存间交互,就是将一个变量从主内存拷贝到工作内存、从工作内存同步回主内存的细节,JVM会保证步骤的原子性,具体包含操作有
- 对主内存的加解锁
- 将变量加载到工作内存,给加载到工作内存的变量进行赋值,再使用工作内存中的变量
- 将工作内存中的变量存入主内存,给存入主内存的变量进行赋值
如果没有这个规范,可能就会导致,相同的并发程序,在不同的JVM上运行的结果不一样,因为volatile、synchronized、Lock等都会失效,需要手动指定内存栅栏,就是工作内存和主内存之间的拷贝和同步
Happens-Before
由于指令操作是有执行顺序的,并且不同的操作可以在同一线程之内,也可以是在不同线程之间,所以Java内存模型通过happens-before规则,解决跨线程的内存可见性问题,简单的说就是:前一个操作的结果,可以被后续操作获取,具体Happens-Before规则
- 一个线程内,后面语句一定能看到前面语句
- 加锁之后一定能看到解锁之前的指令和其他操作
- 使用volatile修饰的变量,所有线程都能立即看到对该变量的修改和其他操作
- 父线程启动子线程,子线程一定能看到启动前父线程的所有指令
- 当前线程在join等待结束后,一定能看到被等待线程的所有指令
- 当前线程被其他线程中断,中断异常一定能被其他线程看到
- finalize()方法一定能看到构造方法中的最后一条指令
- 并发工具类也有Happens-Before原则
- [线程安全容器](# HashMap、Hashtable、ConcurrentHashMap的区别)get一定能看到之前的存入动作
- [CountDownLatch、CyclicBarrier、Semaphore](# 同步工具)、Future、线程池等线程工具类也有Happens-Before
指令重排序
重排序的目的是为了,在执行后续指令时,避开回取下一条指令所需数据造成的等待,从而提高运行效率,要考虑到线程内有序,线程间无序,所以并不是所有的指令都能重排,比如
- 写后读、读后写,这两组操作,交互顺序会导致读到的数据是错误的
- 写后写,交互顺序会导致第二次被第一次覆盖
创建对象的过程
为对象分配内存简单的说,就是从内存区域中划出一块,让一个对象的引用指向这块区域,再将对象初始化就可以使用了,从内存区域划分一块区域的细节操作有:指针碰撞法和空闲列表法
- 指针碰撞法:假设Java堆内存没有内存碎片(这显然是不现实的),使用一个指针指向分界点,一侧已经使用,一侧未被使用,当要分配内存时,将指针从分界点,向未被使用的一侧进行移动,移动大小就是想要分配的内存大小
- 空闲列表法:JVM通过维护一个,记录着可用的内存块信息列表,当要分配内存时,从列表中找出一个足够大的内存块进行分配,然后再更新列表
因为堆是线程共享的,所以在同一时间,就会有多个线程都在堆上申请内存,就有可能会出现多个线程申请的是同一块内存,所以JVM必须要保证内存分配是线程安全的,同时使用CAS和TLAB
- CAS:乐观锁,假设没有冲突,若有冲突,失败后再重新申请,直到成功分配为止
- TLAB:线程本地分配缓冲区,JVM为每个线程预先分配一块内存,JVM在为线程中的对象分配内存时,首先在这块预分配的区域进行分配,当这块区域不够时,在采用CAS方式进行分配
其实就是JVM为了加快内存分配的效率,还要避免出现并发冲突,提前为每个线程预分配一块内存,通常来说只有Eden区域的1%左右,虽然TLAB这块区域是在堆上,但是分配这个动作上是线程独享的,分配完成后,在后续的使用上任然是线程共享的,所以较真的来说Java堆内存也不一定是线程共享
类加载机制
通俗的说就是通过类的全限定名,来获取类的二进制字节流,将二进制字节流转化为方法区的运行时数据结构,并在堆上创建一个Class对象,来向外提供访问接口,详细的说就是类的整个生命周期所经历的过程
类的整个生命周期
- 类加载:加载class文件的二进制数据
- 类连接:将读入内存的类,合并到JVM运行时环境中,包括验证类的正确性、准备静态变量的内存、解析将常量池中的符号引用转化成直接引用
- 类初始化:为类的静态变量赋初始化值
- 类卸载:JVM自带类加载器加载的类是不会被卸载的,用户自定义类加载器加载的类是可以被卸载的,当一个类的Class对象不再被引用时,这个Class对象的生命周期就结束
类加载器
Java虚拟机自带的类加载器有
- 启动类加载器:用于加载基础模块,Java程序内不能直接引用
- 平台类加载器:用于加载一些平台相关的模块
- 应用程序类加载器:用于加载应用级的模块,以及
classpath
路径下的所有类库 - 自定义类加载器:就是继承
ClassLoader
复写findClass()
方法
JVM规范,如果预测到类将要被使用,允许预加载,所以当加载的Class文件缺失时,只会在第一次主动使用时报LinkageError错
双亲委派模型
JVM中的ClassLoader一般采用双亲委派模型,要求除BootstrapClassLoader外,其余的类加载器都应该有自己的父级类加载器,父子关系是组合而不是继承
JDK1.8:是从子级类加载器开始,将请求委托给父类的加载器去执行,直到根加载器,如果根加载器都没找到,在给子类加载器反馈,子类加载器再从自己的classpath中找,如果一路下来都无法找到,就会抛出ClassNotFoundException异常
JDK1.9:由于引入了模块化,所以是从子级类加载器开始,先在自己的模块化中找,如果找不到就委派给父级类加载器,直到根加载器,之后步骤和JDK1.8是一样的
双亲委派模型主要保障了,相同的类只会被加载一次,对于已经加载的系统类,无法在加载同包同名的类,从而保证了系统类不会被恶意修改
对于供应商只定义接口,由不同厂商定义实现的模式来说,双亲委派模型就存在问题,因为都是子委派給父,导致父加载器无法向下识别子加载器加载的资源,比如说JDBC加载驱动,只能在运行期间,由子加载器加载资源,MySQL的驱动类是通过线程上下文类加载器解决了该问题的
垃圾回收算法
判定垃圾
想进行垃圾回收的话,首先要判断是否是垃圾,垃圾判定算法有引用计数法和可达性分析法两种
- 引用计数法:给对象添加一个引用计数器,有人引用就加1,引用失效就减1,虽然简单,但是存在循环引用问题
- 根搜索算法:从根节点向下搜索对象节点,如果一个对象到根节点之间没有连通的话,就会被视为垃圾,可作为根节点的对象可以有很多,比如局部变量、被synchronized关键字持有的对象等,如果JVM在运行期间,都按照跟搜索算法,从根向下遍历判定对象是否是垃圾的话,性能会很低,所以就使用了用来描述对象之间引用关系,OopMap数据结构,直接扫描OoMap就可以判断出对象是否是垃圾
判断是否是垃圾的步骤就是,通过根搜索算法判断对象是否可达,在看是否有必要执行finalize()
方法,在这个方法中可以自救一次,两个步骤走完后对象任然没有人使用,那就是垃圾
垃圾回收算法
判断完垃圾后进行回收,java中有三种垃圾回收算法,分别是标记清除法、标记整理法、复制算法
- 标记清除法:在标记阶段,先标记出要回收的对象,在回收阶段统一回收,会有内存碎片
- 复制算法:把内存分成相等的两块,每次使用其中一块,当一块使用完,就把这块上还存活的对象拷贝到另一块,然后把这块清空,没有内存碎片,但是内存浪费较大,在存活对象较多时,效率较低
- 标记整理法:标记过程跟标记清除法一样,但后续不是直接清除可回收对象,而是让所有存活对象往一端移动,然后直接清除边界以外的内存,没有内存碎片
综合应用这三种垃圾回收算法的特点,就有了分代回收算法,将堆共分成新生代、老年代
- 老年代:存放经过多次垃圾回收都回收不掉的对象,以及新创建的大对象会直接存放到老年代中
- 新生代:存放新创建的对象,新生代又包含Eden区、存活区,区域的比例可通过参数进行设置,默认是8:1
- 新创建对象会先放到Eden区,之后转向存活区
- 存活区又分为大小相同的from区和to区,并且只能使用一边儿,因为使用的是复制算法,如果存活区空间不够,就需要依赖老年代进行分配担保,将放不下的对象直接进入老年代
分配担保
- 在发生MinorGC前,JVM会检查老年代中最大可用连续空间,是否大于新生代所有对象的总空间
- 如果大于就可以确保MinorGC是安全的
- 如果小于,JVM会检查是否设置了允许担保失败
- 如果允许失败,就继续检查连续空间是否大于,之前晋升到老年代对象的平均大小
- 如果大于,就尝试进行一次MinorGC,否则就进行一次FullGC
触发GC的情况
GC分为:
- MinorGC/YoungGC:是发生在新生代的收集动作
- MajorGC/OldGC:是发生在老年代的GC,目前只有[CMS收集器](# 垃圾收集器)会有单独收集老年代
- MixedGC:收集整个新生代和部分老年代,目前只有G1收集器会有这种行为
- FullGC:收集整个Java堆和方法区的GC
当新生代内存不足时可能会触发新生代的GC,当老年代内存不足时可能会触发老年代的GC或FullGC
GC还可能引起STW,STW(Stop The World)是Java中一种全局暂停现象,全局暂停就是所有Java代码停止运行,虽然native代码可以执行,但不能与JVM进行交互,如果长时间服务停止无响应,对于高可用系统还可能引起主备切换
垃圾收集器
串行收集器
Serial/Serial old:串行收集器,GC单线程;垃圾收集时会STW,新生代采用复制算法,老年代采用标记整理法,对于单CPU,由于没有多线程的交互开销,可能更高效,Serial是Client模式下默认的新生代收集器
并行收集器
ParNew :并行收集器,GC多线程;垃圾收集时会STW,新生代采用复制算法,在并发能力好的CPU环境中,STW时间要比串行收集器短;但对于单CPU或并发能力差的CPU来说,由于多线程交互的开销,可能比串行收集器更差,是在Server模式下首选的新生代收集器
Parallel Scavenge/Parallel old:并行收集器,垃圾收集时会STW,新生代采用复制算法,老年代采用标记整理法,跟ParNew类似,但更关注吞吐量,能最高效的利用CPU,适合后台应用
CMS收集器
CMS(Concurrent Mark and Sweep):并发标记清除收集器,使用的是标记清除法,GC线程和用户线程同时运行,低停顿,并发执行对CPU资源压力大;无法处理在回收过程中产生的垃圾,可能导致FullGC;标记清除法会导致大量内存碎片,从而在分配大对象时有可能会触发FullGC
G1收集器
G1(Garbage-First):是一款面向服务端应用的收集器,与其他收集器相比
- 整体上采用标记整理法,局部通过复制算法,不会产生内存碎片,能充分利用多CPU多核环境的硬件优势,尽量缩短STW
- G1将内存划分成多个独立的Region,虽然保留了分代思想,但不是物理隔离,而是一部分Region的集合,也就是说无需Region连续,当分配一个超过Region一半大对象,可能会横跨多个Region区域
- G1的停顿是可预测的,能指定在一段时间内,消耗在垃圾收集器上的时间不能超过多长时间
- 并且还会跟踪各个Region中垃圾堆的价值大小,在后台维护一个优先列表,每次根据允许时间来回收价值最大的区域,从而保证在有限时间内的高效收集
四种引用类型
- 强引用:手动new出来的对象,不会被回收
- 软引用:使用SoftReference来实现,表示还有用但并不是必须的对象,如果垃圾回收后,内存还是不够才会被回收掉
- 弱引用:使用WeakReference来实现,表示非必须对象,比软引用弱,垃圾回收时会被回收掉,[ThreadLocal](# ThreadLocal)的静态内部类ThreadLocalMap的key就是使用的弱引用,用来防止内存泄漏
- 虚引用:使用PhantomReference来实现,最弱的引用,垃圾回收时会回收掉
OOM和SOF
如果有内存泄漏就会出现,垃圾回收时间越来越长、FullGC的次数越来越多、老年代内存越来越大,并且每次FullGC后老年代内存没有被释放,所以一般通过jconsole、jmc工具对垃圾回收前后做对比,同时根据对象引用情况去分析,辅助去查泄漏点
每个线程默认会开启1MB的栈空间,用于存放栈帧、调用参数、局部变量等,对大多数应用来说,默认值太大了,使用-Xss参数调整栈空间大小,若出现栈溢出抛出StackOverflowError异常,一般都是递归调用没有退出或循环调用造成的,在内存不变的情况下,减少线程调用栈,可产生更多的线程
Comments NOTHING