java内存区域和垃圾收集器

第二章

Jvm运行数据区:程序计数器,java虚拟机栈,本方法栈,堆,方法区。线程共享的区域:堆,方法区。其余都是线程隔离的

程序计数器:记录的是当前线程正在执行的虚拟机字节码指令的地址特点1)线程私有,一个处理器某一时刻只会执行一条线程,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器。2)OutOfMemoryError

Java虚拟机栈:描述java方法执行的内存模型每个方法从调用到执行的过程,对应着一个个栈帧在虚拟机中入栈到出栈的过程。一个方法对一个栈帧。栈帧存放的内容:局部变量,操作数栈,动态链接,方法返回地址局部变量表,存放了编译期间各种基本类型(8)对象引用(即指向堆区的指针,真正的对象在堆区)和returnAddress类型(指向了一条字节码指令的地址)64longdouble占用2局部变量空间(slot)其余的数据类型只占用1个局部变量表的分配时机:在编译期间完成分配,在方法运行期间不会改变表的大小经常说的内存和栈内存,后者指的就是java虚拟机栈,或者说是虚拟机栈局部变量表部分。发生的异常:StackOverflowError(当线程请求的栈深度大于虚拟机所允许的栈深度时抛出)OutOfMemoryError(无法申请到足够的内存时抛出该异常)。栈中数据和堆中数据销毁并不是同步的,方法一旦结束,栈中的局部变量立即销毁,但是堆中对象不一定销毁,因为有其他变量可能也指向这个对象,直到栈中没有其他变量指向堆中的对象时,等待垃圾回收时它才销毁

本地方法栈:与java虚拟机栈类似,只是java虚拟机栈虚拟机执行java方法服务本地方法栈则为虚拟机使用到的Native方法服务。 

:是jvm管理的内存中最大的一块区域,此区域唯一的目的就是存放对象实例。对象的成员变量也是存放在堆上的。虚拟机启动时创建如果堆中没有内存来完成实例分配且堆无法再扩展时,抛出OutOfMemoryErrorJava可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。主流的虚拟机都把实现成可扩展的。

方法区存储类信息,类加载器的引用、常量、静态变量等数据。如果方法区无法满足内存分配需求时,抛出OutOfMemoryError该区域的大小可扩展,这个区域的内存回收目标主要是针对常量池的回收和类型的卸载。

运行时常量池(String存在此处):方法区的一部分,用于存放编译器生成的各种字面量和符号引用(类和接口的全限定名,字段名称方法名称等)。常量池不一定只有编译才产生,运行期间也可以将新的常量放入池中如果方法区无法申请足够的内存时,抛出OutOfMemoryError。常量池有字符串常量池,整形常量池(范围-128~127)

只有程序计数器没有OOM

 

HotSpot虚拟机对象分配布局:

1)以为例,阐述对象的分配布局和访问。对象创建分为类加载分配内存、同步控制、初始化、对象头设置过程。

类加载虚拟机遇到new指令时,首先去检查这个指令的参数能否在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过,如果没有,则先执行类加载过程(七章讲的就是类加载过程)

分配内存:对象所需内存的大小在类加载完成后便可以完全确定,为对象分配内存就是把一块确定大小的内存从java堆中划分出来。划分的方式有两种:1)指针碰撞,如果堆中内存绝对规整的,指针是用过的,另一边是空闲的,那分配内存就是把指针向空闲那边挪动一段距离。2)空闲列表,如果堆中内存不是规整的用过的和空闲的互相交互,虚拟机就要维护一个列表记录哪些内存可用,分配的时候从列表中找到一块足够大的空间划分给对象实例即可。

同步控制:对象创建在虚拟机中是非常频繁的,并非线程安全,可能正在给A对象分配地址,指针还没修改,B对象同时使用了原来的指针来分配内存。解决方案有两种,1)分配内存空间的动作进行同步处理,虚拟机采用CAS配上失败重试的方法保证更新操作的原子性。2)按照线程划分空间,每个线程java堆中预先分配一块内存,哪个线程需要分配内存,就在哪个线程的内存缓冲区上分配

内存分配完成后,虚拟机要把分配到的内存空间都初始化为零值(不包括对象头)这一步保证了对象的实例字段在java不赋初始值就可以直接使用,程序能访问到这些字段的数据类型对的零值。

对象头的设置:如设置对象的哈希码,对象GC分代年龄等

执行目标对象内部的<init>方法:初始化成员变量的值,调用目标对象构造方法等

2)对象的内存布局对象在内存中存储的布局可以分为3:对象头,实例数据,对齐填充。 

对象头包括两部分信息:存储对象自身的运行时数据(Mark Word)类型指针。Mark Word:存储HashCodeGC分代年龄锁状态标志,线程持有锁。类型指针:即指向方法区对象数据的指针,如果对象是一个数组,则对象头中必须有个数据记录数组长度。

实例数据:即程序中定义的各种类型字段内容,无论父类继承的还是子类自己定义的都要记录下来。

对齐填充:没有什么特别含义,仅仅是占位符的作用 

3)对象的访问定位:java程序需要通过栈上的reference数据来操作堆上的具体对象,访问堆中对象主要有两种方式:句柄访问和直接指针。

句柄访问:java堆中划分出一块内存作为句柄池,reference存的是对象的句柄地址,句柄中包含了对象实例数据与类型数据的地址信息。对象被移动时只会改变句柄中的数据指针,而reference本身不用修改

直接指针:reference中存的是对象地址好处是速度快节省了一次指针定位的时间开销。

 

第三章

1、对于java方法区,只有程序运行期间才会知道创建哪些对象,内存的分配回收动态的,垃圾收集器关注的是这部分内GC回收前第一件事就是确定这些对象哪些活着哪些死去。两种方法:引用计数法和可达性分析法。

引用计数:给对象添加引用计数器,当有地方引用它就加一,引用失效就减一。为0代表对象被使用。但不被主流采用,因为它解决不了对象之间的互相循环引用问题。

可达性分析:以GC Roots(比如虚拟机栈引用的对象,方法区中类静态属性引用的对象和方法区中常量引用的对象)作为起点向下搜索,搜索走过的路径称为引用链,如果一个对象到GC Roots没有引用链连接,则该对象不可的,会被回收。

四种引用:

  强引用:类似Object obj = new Object( )这类的引用,如果在方法内部有强引用,在方法运行完成退出方法栈后,对象才能被回收。如果是个全局变量,除非引用被置为null,Object obj = null,否则不会被垃圾回收

  引用:还有用,但并非必需的对象,当内存不够的时候,对象会被回收的

  弱引用:当垃圾收集器工作时,无论当前内存是否足够,都会回收掉被弱引用关联的对象。WeakReference类来实现软引用。ThreadLocal和WeakHashMap内部都是使用了弱引用,用来保证那些不被用到的key值,在垃圾回收的时候可以被回收掉

   引用:和没有任何引用一样,在任何时候都可能被垃圾回收器回收

可达性分析中不可达的对象,也不是非死不可,要真正宣告一个对象死亡,至少经过两次标记过程:若对象在进行可达性分析后发现没有与GC roots相连的引用链,那么他将会被第一次标记并进行一次筛选,筛选的条件是该对象是否有必要执行finalize()方法,当对象没有重写finalize()方法或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为没必要执行。

若该对象被判定为有必要执行finalize()方法,则这个对象会被放在一个F-Queue队列,finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模标记,若对象要在finalize()中成功拯救自己,只要重新与引用链上的任何一个对象建立关系即可。那么在第二次标记时他们将会被移出“即将回收”集合。

2常见的垃圾收集算法内存回收的方法论

1)标记-清除算法:标记出所有要回收的对象,标记完成后统一回收。缺点是产生大量不连续的内存碎片,而且标记和清除的效率都不高

2)复制算法:把可用内存划分为大小相等的两块,每次只使用其中的一块。当这一块内存使用完了,则把还存活的对象复制到另一块上面。然后把已经使用过的内存一次清理掉。hotspot虚拟机:将内存()分为一块较大的eden空间和两块较小的survivor空间。默认比例8:1:1,即每次新生代可用内存空间为整个新生代容量的90%,每次使用eden和一个survivor。当回收时,将edensurvivor中还存活的对象一次性复制到另一块survivor上。最后清理掉eden和刚才用过的survivor,若另一块survivor空间没有足够内存空间存放上次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。为什么是811?因为90%的对象会被回收,剩下的10%放到survivor上。

  为什么要有survivor区?如果没有Survivor,Eden区每进行一次Minor GC,存活的对象就会被送到老年代。老年代很快被填满,触发Major GC。因此Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代

  两个survivor区分别称为“From”区和“To”区。新生代对象在Eden区创建,第一次Eden满了触发Yong GC后,还存活的对象将会被复制到Surviver区的“From”区,此时“To”区是空的。下一次Eden满了触发Young GC的时候,Eden区还存活的对象和”from“区的对象会复制到”To“区,然后下一轮S0与S1交换角色,如此循环往复。如果对象的复制次数达到16次,该对象就会被送到老年代中。为什么没有分更多的survivor区?如果Survivor区再细分下去,每一块的空间就会比较小,很容易导致Survivor区满,因此,两块Survivor区是经过权衡之后的最佳方案。

  如果年轻代只分为Eden区和Surviver区两个区域并且比例是8:2的时候,内存的回收和分配情况会怎么样。第一次Yong GC后,Eden区还存活的对象移动到Surviver区,Surviver区还存活的对象保留在Surviver区,而这些对象的内存是不连续的,Surviver区里就会产生很多内存碎片,这就会导致有些大对象要移动到Surviver区的时候,没有足够的连续内存进行分配,而不得不移动到老年代中,增加老年代的负担,降低效率

  复制算法的优点是?

  a、在年轻代新增survivor区,有利于减轻老年代的负担,尽可能的让大部分对象在年轻代通过较高效的Yong GC回收掉,不至于老年代里存放的对象过多导致内存不足而进行频繁的Full GC操作

  b、这种分区有利于减少内存碎片的产生

  复制算法的缺点?

  a、有个survivor区是没有利用起来的

  b、当对象存活率较高时,复制算法效率会下降。

3)标记-整理算法:标记过程和标记-清除算法一样,但后续步骤是让all存活对象都向一端移动,然后直接清理掉边界以外的内存。

4)分代收集算法:新生代用复制算法,老年代存活时间长,总复制的话效率低,所以用标记清除或标记整理算法。

3、垃圾收集器:内存回收的具体实现前三个是新生代,后三个是老年代

1) Serial收集器单线程收集器(单线程的意义不仅仅说明它会用一个cpu或者一垃圾收集线程去完成垃圾收集工作,更重要的是它进行垃圾收集的时候,必须暂停其他工作线程,直到他收集结束),采用复制算法

2) ParNew收集器Serial收集器的多线程版本,是许多运行在server模式下的虚拟机首选的新生代收集器。采用复制算法。

3) Parallel Scavenge收集器多线程收集器,目标 达到一个可控制的吞吐量合理利用CPU时间,适合在后台运算,没有太多的交互。采用复制算法

4) Serial oldSerial的老年代版本,单线程,标记-整理。

5) Parallel oldParallel Scavenge的老年代版本,多线程,标记-整理

6) CMS收集器针对的是老年代对象,一种以获取最短回收停顿时间为目标的收集器。标记-清除,有4个过程:

a、初始标记(标记直接与gc roots连接的对象,这个阶段要STW(即所有java应用程序都要暂停),但是很快就完成了),这个过程仅仅标记gc roots直接关联的对象,不需要做整个引用的扫描

b、并发标记(对「初始标记」中被标记的对象进行整个引用链的扫描,应用程序的线程和这个扫描的线程并发执行,用户不会感到停顿)

c、重新标记(因为并发标记时有用户线程在执行,一部分对象未被标记,所以要把剩余对象进行标记,这个过程也要STW)

d、并发清除(这个过程中应用程序和收集线程并发执行)

优点:并发收集,低停顿。

缺点: 1)对cpu资源敏感,占用部分线程及CPU资源。2)产生大量内存碎片,因为采用了标记清除算法。如果CMS收集器产生大量内存碎片,会出现个情况,在young gc时有大对象需要晋升到老年代,本来老年代空间有很大剩余但是无法找到连续的空间分配当前对象,导致提前触发full gc。怎么解决:使用-XX:+CMSFullGCsBeforeCompaction参数,表示在进行多少次Full GC之后进行内存碎片整理,默认为0,即每次Full GC之后都进行内存碎片整理。

7) G1收集器新生代和老年代都针对,后序会替换掉CMS。将堆分为约2048个大小相等的区域region,且在jvm生命周期内不会改变,避免全区域垃圾收集,新生代老年代不再物理隔离,只是部分region的集合(原来的垃圾收集器中,新生代和老年代是物理隔离的)每个region大小的2的指数(1M,2M,4M…)。可以通过-XX :G1HeapRegionSize设定。根据各个region价值大小,在后台维护一个优先列表,优先回收价值最大的region。每个region被标记为E(eden),S(survivor),O(old),H(humongous)H存储的是巨型对象,当新建对象的大小超过region大小的一半时,直接在新的一个或多个连续的region中分配,并标记为H,H是old区的一种,所以大对象是在老年代中分配的。每一个H对象的内存分配都会触发一次gc

大对象分配大致的过程:

 a、尝试垃圾回收

 b、尝试分配对象

 c、失败则再次尝试垃圾回收,之后再分配

 d、成功分配或失败达到一定次数,分配失败

G1的垃圾回收模式分为young gcmixed gcfull gc 

Young gc(Minor gc):一般对象除了巨型对象都是在eden region中分配内存,当所有的eden region被耗尽无法申请内存时,就会触发一次young gc,执行完young gc后,活的对象会被拷贝到survivor或者晋升old region中。G1收集器有young gc过程

old gc(Major gc): 指的是收集老年代,只有 CMS 的 concurrent collection是这个模式。

Mixed gc:当越来越多的对象晋升到old region时,jvm触发mixed gc,除了回收整个eden,还会回收部分old region和大对象mixed gc过程类似于cms的过程。一旦老年代占据堆内存的45%了,就要触发Mixed GC,此时对年轻代和老年代都会进行回收,对应的参数设置:“-XX:InitiatingHeapOccupancyPercent”,他的默认值是45%,可以进行按需要修改。mixed gc是G1独有的概念。

Full gc:如果对象分配速度过快,mixed gc来不及回收,导致老年代被填满,就触发一次full gc,full gc会回收新生代,老年代,永久代,速度很慢。要尽量进行调优来避免full gc。CMS和G1收集器都有full gc。当方法区空间不够用,也会触发full gc

 G1收集器,用户可以指定收集操作在多长时间内完成,即可预测的停顿。因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1收集器工作流程:

a) 初始标记:标记GC Roots能直接关联到的对象,这阶段需要STW,但耗时很短。

b) 并发标记:是从GC Roots开始堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。由于和用户程序并发执行,会产生没有被标记的新对象,这部分新对象会用SATB算法进行记录。SATB即snapshot at the beginning,SATB会创建一个对象图,相当于堆的逻辑快照,当赋值语句发生时,对象图会发生改变,这部分改变会被记录在SATB日志或缓冲区中。

c) 最终标记:并发标记期间有用户线程执行,所以该阶段需要对SATB日志缓冲区记录的漏标对象进行重新标记,这个阶段要STW。

d) 筛选回收: 首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。这个阶段STW,回收的对象包括新生代,老年代和大对象。

 G1怎么解决跨代引用的问题?

 跨代引用的概念:发生young gc时,如果一个新生代对象a没有被GC ROOTS(虚拟机栈中对象)引用,它是可以被回收的么?不一定,因为它可能被老年代对象引用了,这样它就不能被回收。所以为了知道对象a是否可以被回收,还需要遍历一遍老年代。老年代对象引用关系很复杂,这个遍历过程很耗时。 

   怎么解决:为了解决扫描的时间成本,引用了记忆集的概念。每个region被分为相同大小的card page,单个card page大小为512B,如果每个region大小是1M,则单个region中有2048个card page。如果堆的大小为1G,则卡表CardTable的长度为2097152(1G/512B),其中每2048个card page分配到了一个region中。具体如下图所示

 每个region中都有个Rset(记忆集),记忆集本身是个hash表,key是引用本region对象的其他region的地址,value是一个数组,数组的元素是引用方的对象所对应的Card Page在Card Table中的下标。如下图所示,区域B中的对象b引用了区域A中的对象a,这个引用关系跨了两个区域。b对象所在的CardPage为122,在区域A的RSet中,以区域B的地址作为key,b对象所在CardPage下标为value记录了这个引用关系,这样就完成了这个跨区域引用的记录。

这样比如regionA发生young GC时,假如regionB是老年代,只需要将B对应的value加入GC Roots一并扫描即可。比起扫描老年代的所有对象,大大减少了扫描的数据量,提升了效率

  CMS收集器和G1收集器区别:

1)使用范围:CMS收集器是老年代的收集器,G1收集器收集范围是老年代和新生代

2)STW的时间:CMS收集器以最小的停顿时间为目标的收集器,G1收集器可预测垃圾回收的停顿时间

3)回收算法:CMS收集器是使用“标记-清除”算法进行的垃圾回收,容易产生内存碎片。G1收集器整体用的标记整理算法,进行了空间整合,局部用的是复制算法

4)G1收集器老年代新生代等是物理不隔离的,且引入了region和记忆集等

5)垃圾回收过程:不一样

  G1收集器的优势:a、能独立管理整新生代和老年代,不需要与其他收集器搭配。b、不会产生较多内存碎片。c、停顿时间可预测。

  G1收集器的劣势:需要记忆集 (具体来说是卡表)来记录新生代和老年代之间的引用关系,这种数据结构在G1中需要占用较多的内存,可能达到整个堆内存容量的20%甚至更多。而且 G1 中维护记忆集的成本较高,带来了更高的执行负载。

  CMS和G1图解

jvm三色标记法: 

标记内存中存活和需要回收的对象,G1和cms都用到了三色标记法,jvm把内存中的对象分为三种颜色,
1)白色:还没有被垃圾回收器扫描的对象
2)黑色:该对象已经被垃圾回收器扫描过,且该对象和其引用的其他对象都是存活的
3)灰色:该对象已经被垃圾回收器扫描过,但该对象引用的其他对象还未被扫描
gc开始时,先把所有对象都标为白色,然后从根对象去遍历内存中的对象,把直接引用的对象标为灰色。然后判断灰色集合中的对象是否有子引用,不存在,就放到黑色集合中。如果存在,就把子应用中的对象放到灰色集合中。按这个步骤不断推导,直到灰色集合中对象都到了黑色集合中,这一轮标记就完成了。最后处于白色集合中的对象就是不可达对象,可以直接被回收

安全点和安全区域:

安全点: 垃圾回收过程中会有STW,但是用户线程在执行时并非在任何地方都能停顿下来开始等待GC,只有在到达安全点时才能暂停。比如循环跳转,异常跳转等会作为安全点。
主动式中断:GC在STW时暂停用户线程,并不是抢占式中断(即立刻把业务线程中断),而是主动式中断。即设置一个中断标志,业务线程执行过程中会不停地主动轮询这个标志,一旦发现这个标志为true,就会在自己最近的安全点上主动中断挂起。
安全区域:指在一段代码片段中,对象引用关系不会发生变化。在这个区域中任意地方开始GC都是安全的。也可以把Safe Region看作是被扩展了的Safepoint。

 

4、内存分配和回收策略:

   对象优先在新生代分配,新生代存放的是很快就被GC回收的或者不是很大的对象,发生在新生代的是minor gc会将新生代中还存活着的对象复制进一个survivor 

 老年代长期存活的对象进入老年代。老年代是存放经历了好几次回收仍然活着或者特别大的对象老年代GCMajor GC什么情况下新生代中对象会进入老年代?1)首先是分配担保机制,minor gc,新生存活的对象大于survivor大小时,这时survivor装不下他们,survivor无法存放的对象就会进入老年代2)survivor空间中相同年龄对象大小的总和>survivor空间的一半,则年龄>=年龄的对象直接进入老年代(无须设定的阈值即15)3) 如果设置了-xx:PretenureSizeThreshold 3M那么大于3M的对象就会直接进入老年代。4在新生代的每一次minor gc都会新生代的对象+1岁,默认情况下年龄阈值是15岁,对象年龄超过此值时就会从新生代进入老年代,可以通过-xx:MaxTenuringThreshold来设置这个临界点。为什么默认年龄阈值是15岁?因为对象的gc分代年龄在对象头中存储,对象头用4bit存储此字段,4bit最大就能代表15。

 jvm的方法区,也被称为永久代。这里存放一些被虚拟机加载的类信息,静态变量,常量和String等数据。这个区中的东西比新生代和老年代还不容易回收,回收效率很低。例如一个字符串abc”已经进入常量池,但是当前系统没有一个String对象引用常量池中的”abc”常量,下次回收时,”abc”就会被清理出常量池。在jdk8中,没有永久代了,类的数据被移到了元空间jvm可以通过对象的元数据信息确定对象的大小,但是无法从数组的元数据确定数组的大小。为什么用元空间替换永久代?

1)永久代内存有上限,容易出现oom。元空间使用的是本地内存,内存上限较大,可以很好的避免oom问题
2)永久代对象通过full gc垃圾回收,即和老年代同时被回收,使用元空间的话简化了full gc过程,不在full gc中进行回收,提升full gc性能

  Minor GCFull Gc/Major GC的区别:前者发生在新生代,java大多数对象都是朝生夕灭,所以前者发生很频繁,回收速度也快。后者发生在老年代,速度比前者慢10倍以上。如果full gc太频繁,会使应用进程停顿,对性能影响较大。Full gc为什么很慢?会清理整个堆空间,包括年轻代,老年代,方法区。 

如何避免频繁full gc?可以进行参数调优

  通过虚拟机的-Xmn参数适当调大新生代的大小,让对象尽量在新生代中被回收掉。通过-XX:MaxTenuringThreshold参数调大对象进入老年代的年龄,让对象尽量在新生代中被回收掉

5、一个例子解释垃圾回收:

某个方法f中定义了此对象:Calendar calendar = new GregorianCalendar(2000,1,30),在堆上创建了GregorianCalendar类的一个对象。方法f执行结束后,引用变量calendar不再有效,因此在f方法中没有创建引用到对象。jvm认识到这一点,会从堆中删除对象,这就是垃圾回收。

6、垃圾回收的最佳做法:

用编程的方式,我们可以调用System.gc()方法来通知jvm进行垃圾回收。当内存已满,且堆上没有对象可用于垃圾回收时,jvm可能会抛出OOM。对象在被垃圾回收从堆上删除之前,会运行finalize()方法,我们建议不要用finalize方法写任何代码。System.gc()会立即回收吗?不会,此方法只是建议jvm对此部分内存进行回收,但是不一定会发生gc

7内存泄漏:java中,内存泄漏是指存在一些被分配有内存的对象,是可达的,但是无用的,也就是程序以后不再使用这些对象。

   典型的内存泄漏场景:单例对象在被初始化后将在jvm整个生命周期中存在(gc roots对象有一种是:方法区中的类静态属性引用的对象,很明显,java单例模式创建的对象被自己类中的静态属性所引用,因此单例对象不会被回收),如果单例对象持有外部对象的引用,那么这个外部对象即使不再使用了,也会被单例对象引用,导致不能被jvm正常回收,导致内存泄漏。如下图:B是单例对象,b一直引用着A,所以即使A不用了,也不会被回收:

8、gc耗时高,排查思路

  gc耗时500ms,看gc日志,发现耗时在Object Copy和String Dedup Fixup比较多, Object Copy耗时多的原因:a、worker线程数不够,cpu性能没有完全发挥。b、存活对象过多,要复制的对象多。针对a,增加worker线程数量,线上机器是24核,算出来大约16个worker线程,手动调整为20。ParallelGCThreads=20,耗时有一些减少,单次耗时减少50ms。针对b,存活对下过多可能是年轻代设置的过于大了,可以调整G1NewSizePercent。在syslab分析平台上分析GC原因,发现大对象分配耗时也比较多,(大对象分配之前会有垃圾回收过程),可以把-XX:G1HeapRegionSize=16m(原来是8m),从而降低大对象分配频率。在syslab上分析,发现mixed gc频率非常高,可以通过调高IHOP的值来降低Mixed GC的频率,将IHOP调整为60,降低mixed gc频率,接口耗时降低100ms。关于String Dedup Fixup导致的耗时较高,这个主要是由-XX:+UseStringDeduplication参数控制的,其作用是进行字符串去重,减少内存占用,但是也有副作用:是在GC时候做的字符串去重,因此可能会增加GC的停顿时间。将+UseStringDeduplication改为-UseStringDeduplication后,单次gc耗时较低100+ms。

  gc日志出现to-space exhausted(https://juejin.cn/post/6844903923648561166),这个阶段JVM没有内存,又不能扩展,对象无法晋升。需要调整-XX:G1ReservePercent

      一个轻量级读取配置的接口耗时较高,因为接口把请求参数和响应参数都打出来了,响应参数有上万条数据。一般情况下日志消息会保存在内存中,达到一定条件才写到日志文件里。去掉日志后,接口耗时从300ms+降低到40ms,young gc时间也从300ms+降到30ms,young gc频率也显著降低。

9、常见参数设置

-Xss:每个线程堆栈大小

-Xms:堆的最小值

-Xmn:年轻代大小,一般推荐年轻代占整个堆的3/8

-Xmx:堆的最大值

-XXSurvivorRatio:新生代中eden和survivor区的大小比值

-XX:MaxGCPauseMillis:表示每次GC最大的停顿毫秒数,G1收集器会在这个时间段内做最有价值的回收,不保证能完成全部内存的回收。一般可设为200ms。这个值不能太小,如果太小意味着每次只能回收一小部分垃圾,导致垃圾堆积,最终导致full gc频率升高。也不能过大,case:MaxGCPauseMillis设置300ms,redis超时时间为200ms,发现压测期间会出现进程单次卡顿时间较长,且redis耗时和失败量会出现突然尖刺。怀疑是gc卡顿引起redis超时,将MaxGCPauseMillis设为200ms,redis超时设为400ms。redis耗时和失败量尖刺现象消失

-XX:G1ReservePercent:堆保留的比例,预留一部分空间防止to-space溢出,默认为10,即保留10%,一般设置为20。如果经常发生新生代晋升失败而导致Full GC,那么可以适当调高此阈值。但是调高此值同时也意味着会存在一些空间的浪费。

-xx:G1NewSizePercent: 新生代初始大小占堆内存百分比,如果过小,会导致young gc过于频繁,如果过大,可能导致old区可用空间小,触发full gc。一般设置为10到20

-XX:G1HeapRegionSize:region大小,如果过小,会有较多对象被视为大对象,大对象分配过程中需要垃圾回收,导致回收频繁较频繁。如果设置过大,会有较多大对象分布在新生代,降低young gc效率。

-XX:InitiatingHeapOccupancyPercent,即老年代对象占比达到这个值再mixed gc,降低mixed gc频率。InitiatingHeapOccupancyPercent调大可以减少mixed gc产生,相应的有oom风险;调小会更快的开始并发标记周期,可能产生更多的mixed gc

-XX:InitialRAMPercentage:初始堆大小占总内存多少,60

-XX:+HeapDumpOnOutOfMemoryError 指定发生OOM时是否要导出堆文件

-XX:HeapDumpPath 指定hprof文件的路径

-Xloggc:指定gc日志路径

第七章

1、虚拟机类加载机制概述:把描述类的数据从class文件加载到内存并对数据进行校验转换解析和初始化最终形成可以被虚拟机直接使用的java类型,这就是虚拟机的类加载机制。Java的类型加载、连接和初始化过程都是在运行期间完成的,为java应用程序提供高度的灵活性。

2类加载的时:类的整个生命周期包括加载、验证、准备、解析、初始化、使用和卸载,其中验证、准备、解析统称为连接。虚拟机没有强制约束类加载时机,但规定了这5情况必须立即对类初始化:1)遇到new、getstaticputstatic、invokestatic指令(生成这几条指令最常见的情景是,使用new实例化对象时,读取或设置一个类的静态字段时,以及调用一个类的静态方法时)2)对类进行反射调用时如果类没有初始化,就要先初始化,如使用Class.forName(String s)方法3)初始化时发现父类还没有进行初始化,4)虚拟机启动时指定的主类,要先初始化这个主类

 3类加载过程:在加载阶段,虚拟机要完成的事情:将class文件读入内存,并为之创建一个class对象。分为三步:1)通过一个类的全限定名获取我们的class文件,比如new Student()jvm就会加载student.class2)将这个字节流所表示的静态存储结构转化为方法区运行时数据结构。3)在内存中生成一个代表这个类的class对象,作为方法区类信息的访问入口。

  验证过程的目的是为了确保class文件的字节流中包含的信息符合当前虚拟机的要求,且不会危害虚拟机自身的安全。验证阶段大致会完成下面4个阶段的检验动作:1)文件格式验证2)元数据验证3)字节码验证4)符号引用验证(字节码验证将对类的方法进行校验分类,保证被校验的方法在运行时不会做出危害虚拟机的事,一个类方法体的字节码没有通过字节码验证,那一定有问题,但若一个方法通过了验证,也不能说明它一定安全)。元数据:字节码描述的数据,如是否继承final类,是否实现父类抽象方法等。

  准备过程:准备阶段是正式为变量分配内存并设置变量的初始化值的阶段,这些变量所使用的内存都将在方法区中进行分配。这时候进行内存分配的仅包括类变量(static修饰的),而不包括实例变量。实例变量将会在对象实例化时随着对象一起分配在java堆中。若public static int a = 123,准备阶段过后a的值是0而不是123,要在初始化之后才变为123,但若被final修饰,public static final int a = 123,在准备阶段后就变为了123

  解析阶段是虚拟机将常量池中的符号引用变为直接引用的过程。符号引用以一组符号来描述所引用的目标和虚拟机内存布局无关。直接引用是直接指向目标的指针相对偏移量等和虚拟机的内存布局是相关的。

  介绍下实例初始化方法(<init>)和类与接口初始化方法(<clinit>)前者是在类实例化时调用的(),后者是在虚拟机装载一个类初始化的时候用到的。前者初始化实例变量,后者初始化静态的类变量。

  初始化过程:类加载的最后一步,到了初始化阶段,才真正开始执行类中定义的java程序代码(或者说字节码)。初始化阶段执行类构造器<clinit>()方法的过程,该方法是编译器自动收集类中所有类变量的赋值动作和静态语句块static{}中的语句合并产生的。一个类定义static的内容,只会被初始化一次。<clinit>()方法与类的构造函数不同,不需要显示的调用父类构造函数,虚拟机会保证在子类的方法执行前,父类的方法执行完毕。<clinit>()方法对于类和接口来说不是必须的,如果一个类没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。接口中没有静态语句块,但是有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法接口类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法只有当父接口中定义的变量使用时,父接口才会初始化,接口的实现在初始化时也一样不会执行接口的<clinit>()方法

  类加载器:虚拟机设计团队把类加载阶段中的通过一个类的全限定名来获取描述此类的二进制字节流这个动作放到虚拟机外部去实以便让程序自己决定如何去获取所需要的类,实现这个动作的代码模块称为类加载器。这是java语言的一项创新,也是java语言流行的原因。

  类与类加载器:对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在java虚拟机的唯一性,每一个类加载器,都拥有一个独立的类名称空间。比较两个类是否相等,只有在这两个类是同一个类加载器加载的前提下才有意义。

  双亲委派模型:只存在不同的类加载器启动类加载器(使用C++实现,是虚拟机自身的一部分)和其他的类加载器(使用java实现,独立于jvm全部继承自抽象类java.lang.ClassLoader)

  

系统提供了三种类加载器:
  启动类加载器:这个加载器负责把<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath下的类库加载到虚拟机内存中,启动类加载器无法被java程序直接引用。
  扩展类加载器:负责加载<JAVA_HOME>\lib\ext下或者java.ext.dirs系统变量指定路径下所有类库,开发者可以直接使用扩展类加载器。
  应用程序类加载器:负责加载开发者自己定义的类,开发者可以直接使用这个类加载器,若应用程序中没有定义自己的类加载器,一般情况下,这个就是程序中默认的类加载器。
  这张图表示类加载器的双亲委派模型,该模型要求除了顶层的启动加载类外,其余的类加载器都应该有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承关系来实现,而是使用组合关系来复用父类加载器的代码。
  双亲委派工作过程:若一个类加载器收到了类加载请求,他首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一层的加载器都是如此,因此所有加载请求最终都应该传送到顶级的启动类加载器。只有当父类加载器反馈自己无法加载时(他的搜索范围中没有找到所需的类),子加载器才会尝试自己去加载。举例:一个用户自定义的类,如com.hollis.ClassHollis,启动类加载器和扩展类加载器中并没有这个类,所以该类会由应用程序类加载器来加载。 

  双亲委派的好处:可以避免类的重复加载,当父加载器已经加载过某一个类时,子加载器就不会再重新加载这个类。

  双亲委派实现过程:在java.lang.ClassLoader的loadClass()方法之中。

 1、先检查类是否已经被加载过

 2、若没有加载则调用父加载器的loadClass()方法进行加载

   3、若父加载器为空则默认使用启动类加载器作为父加载器。

   4、如果父类加载失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。

  类加载的过程:当运行一个程序时,jvm启动,运行bootstrap classloader(启动类加载器), classloader加载核心API(Ext classloaderapp classloader也在此时被加载)then调用Ext classloader(扩展类加载器)加载扩展API,最后app classloader(应用程序类加载器)加载classpath目录下定义的class,这就是一个程序最基本的加载流程。通过classloader加载类实际上就是加载的时候并不对该类进行解析,因此也不会初始化,而class类的forName方法则相反,使用此方法加载的时候会将class进行解析和初始化。

      双亲委派模型的好处:egobject类,它存放在rt.jar中,无论哪个类加载器要加载这个类,最终都是委派给处于模型顶端的启动类加载器加载,因此object类在程序的各种加载环境中都是同一个类。

      如何破坏双亲委任模型?上文提到,双亲委派的实现是在java.lang.ClassLoader的loadClass()方法中所以我们只需要自定义个类加载器,重写其中的loadClass方法并且不做双亲委派的实现即可。比如在tomcat中,就是通过打破双亲委派,避免多个不同web应用类加载冲突,从而tomcat可以运行多个web应用。tomcat有多个自定义的类加载器,其中有一个是WebAppClassLoader,WebAppClassLoader重写了loadClass方法,优先加载当前应用目录下的类,如果当前找不到,才一层层往上走,从而达到web应用级别的隔离。

第十二章:

1、 处理器、高速缓存和主内存的关系java并发采用的共享内存模型cpu算速度比内存读写快得多。导致cpu可能会花很久来等待数据到来或把数据写入内存。所以cpu不会直接访问内存,取而代之的是cpu缓存,cpu缓存是位于cpu与内存之间的临时存储器,容量小于内存但交换速度比内存快得多。当cpu调用大量数据时,先从缓存中读取从而加快读取速度。程序把数据从内存加载到cpu缓存,cpu直接从缓存中读取数据。运算完成后把结果写回到缓存,再将缓存中的数据写回内存。

2java内存模型:jvm定义的,为了屏蔽掉各种硬件和操作系统的内存访问差异,实现让java程序在各种平台下都能达到一致的内存访问效果。

   Java内存模型规定所有变量都存在主存中,每个线程都有自己的工作内存(类似前面说的cpu缓存),工作内存中保存了被该线程使用到的变量的主内存副本拷贝(注意该变量不包括局部变量和方法参数,这些是线程私有的,不会被共享)。线程只可以修改自己工作内存中的数据然后再同步回主内存,主内存由多个线程共享。当一个变量在多个线程中都有副本,就会出现缓存不一致问题,a = a + 1; 可能两个线程分别读取a存入各自的cpu缓存中,线程11操作,把a的最新值写入内存,此时线程2高速缓存中a的值还是0,加1后为1,然后线程2a写入内存。这样最终结果是1不是2

 

内存间的交互操作:一个变量如何从主内存拷贝到工作内存,如何从工作内存同步回主内存的实现细节,有八种,都是原子的,不可再分的(后面定义了一个等效的判断原则:先行发生原则)

1) lock作用主内存的变量,它把一个变量标识为一个线程独占的状态。

2) unlock:作用主内存的变量它把一个处于锁定的变量释放出来,释放后的变量才可以被其他线程锁定。

3) read:作用主内存的变量他把一个变量的值从主内存传输到线程的工作内存,以便随后的load操作使用。

4) load:作用于工作内存变量它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

5) use作用于工作内存变量它把工作内存中一个变量的值传递执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。

6) assign:作用于工作内存变量,他把一个从执行引擎收到的值付给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作

7) store:作用于工作内存变量他把工作内存中一个变量的值传送到主内存中,以便随后write使用。

8) write:作用主内存的变量他把store操作从工作内存汇总得到的变量值放入主内存的变量中。

readload是一组顺序执行的,不会单独出现,storewrite同理。

    内存屏障:是一个cpu指令。1)强制把对缓存的修改操作立即写入主存,因此任何cpu上的线程都能读取到这些数据的最新版本2)如果是写操作,它会导致其他cpu中对应的缓存数据无效3)确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面。即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。

  Happens-before:java内存模型中,如果两个操作之间具有happens-before关系,那么一个操作的执行结果需要对另一个操作可见,如果操作A先行发生于操作B,那么操作A产生的影响能被操作B观察到,这两个操作可以在同一线程或不同的线程。注意:时间的先后顺序与happens-before之间没太大关系。happens-before的规则如下:

 1)程序顺序规则:一个线程内,按照代码顺序,书写在前面的操作happens-before于书写在后面的操作。

   2)监视器锁规则:一个unlock操作happens-before于后面对同这个锁的lock操作

   3) Volatile变量规则:对一个变量的写操作happens-before后面对这个变量的读操作。(解读:如果一个线程先去写一个变量,然后一个线程去读取,那么写入操作肯定会先行发生于读操作)

   4)传递规则:如果操作A happens-before于操作B,操作B happens-before于操作C,则操作A happens-before操作C

 

Java内存模型的三个特征:

   原子性:一个操作要么全部执行且执行中不被打断,要么就都不执行Java内存模型规定所有变量都存在主存中,每个线程都有自己的工作内存(类似前面说的cpu缓存)。java只保证了基本数据类型的读取和赋值是原子性操作(x = 10是原子性操作,x = x+1包括三个操作,读取x值,进行加一操作,写入新的值,这些操作单个是原子性,合起来就不是了),如果想实现更大范围的原子性,可以通过synchronized和lock来实现。

   可见性:多个线程访问同一变量时,一个线程修改了这个变量的值,其他线程立即就能看到。

   有序性:即程序执行顺序按照代码先后顺序执行,jvm在真正执行代码时有时会重排序,但会保证最终结果和代码顺序执行结果相同。重排序不会影响单个线程内程序执行的结果,但多线程下会受影响。所以要想并发程序正确进行,必须保证这三个原则。 

Volatile关键字

1)保证变量在多线程之间的可见性,当一个共享变量被volatile修饰,它保证修改的值立即被更新到主存,当其他线程需要读取时,会被强制去内存中读取新的值而不是从线程的工作内存中取得变量的值。而普通共享变量不能保证可见性,因为普通变量被修改后什么时候写入主存是不确定的,当其他线程去读取时,内存中可能还是旧的值synchronized和lock也能保证可见性,在释放锁之前把变量的修改刷新到主存当中。final关键字也能实现可见性,被final修饰的字段在构造器中一旦初始化完成,在其他线程中就能看到final字段的值。

   2)禁止指令重排序:当程序执行到Volatile变量的读操作或者写操作时,在其前面的操作更改肯定全部已经进行。且结果对后面的操作可见,在其后面的操作肯定还没进行。2)在进行指令优化时,不能将在对Volatile变量访问的语句放在其后面执行,也不能把Volatile变量后面的语句放到其前面执行。普通变量的话仅仅能保证该方法的执行过程中能获取正确的结果,而不能保证代码执行顺序与程序中代码顺序一致。例子如下:

 

由于flagVolatile变量,在进行指令重排序的过程中,不会把语句3放到1和2前面,也不会把语句3放到4和5后面Volatile关键字保证执行到语句3时,1和2肯定完毕volatile可以保证有序性synchronized和lock也能保证有序性(保证每一时刻只有一个线程执行同步代码)

   3) Volatile不能保证原子性:

如图代码:最后输出的结果并不是10000,而是小于10000。自增操作包括读取变量原始值,进行加1操作,写入工作内存。那么就可能发生这种情况:某时刻变量inc是10,线程1对变量进行自增操作,线程1先读取inc的原始值,然后线程1被阻塞了。线程2对变量进行自增操作,线程2也去读取变量inc的原始值,由于线程1只是对变量inc进行读取操作还没修改,所以不会导致线程2的工作内存中缓存变量的inc的缓存无效,所以线程2会直接去主存读取inc的值,发现inc的值是10,然后加1操作,并写回工作内存最后写入主存。然后线程1接着进行加1操作,由于已经读取了inc的值,此时在线程1的工作内存中inc的值仍然是10,所以线程1对inc加1操作后inc的值为11,然后11被写入工作内存,然后写入主存。那么两个线程分别进行了自增,而inc只增加了1。

    注意:

   4)实现原理:加入Volatile关键字时,会多出一个lock前缀指令。lock前缀指令实际上相当于一个内存屏障。

   5) Volatile和synchronized的区别:

前者修饰变量,后者修饰方法,代码块。

多线程访问前者不会发生阻塞,而后者会发生阻塞。

前者保证数据的可见性,不保证原子性。后者保证原子性,也保证可见性。

前者解决的是变量在多个线程之间的可见性,后者解决的是多个线程之间的访问资源的同步性。

3、java与线程:

   实现线程的三种方式:使用内核线程实现(由操作系统内核通过操作调度器来调度线程,相对代价较高),使用用户线程实现(不需要内核支援,用户完成线程的建立和调度),使用用户线程加轻量级进程混合实现。

    线程的调度:是指系统为线程分配处理器使用权的过程:有两种:协同式线程调度(线程的执行时间由线程本身来决定),抢占式线程调度(线程由系统来分配执行时间,线程的切换由系统来决定)

十三章:

1、线程安全的定义:多个线程访问一个对象,不用考虑这些线程在运行时的调度和交替执行,也不用进行额外的同步,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。

线程安全的分为五类:不可变、线程绝对安全、线程相对安全、线程兼容、线程对立。

不可变:一定是线程安全的,如final基本数据类型,String类型等。

线程绝对安全:java api中标注自己是线程安全的类,大多数不是绝对的线程安全,如Vector容器,我们要在调用端做额外的同步措施

线程相对安全线程相对安全就是我们通常意义上讲的线程安全,它需要保证这个对象单独的操作线程安全的。

线程兼容:指对象本身不是线程安全的,可以通过在调用端正确的使用同步手段来保证其线程安全容器ArrayListHashMap

线程对立:无论调用端是否采取同步措施,都无法在多线程中使用,java中出现的较少。 

2、线程安全的实现方法:

    互斥同步:多个线程并发访问共享数据时,保证共享数据在同一个时刻,只被一个或一些线程使用,而互斥是实现同步的一种手段,临界区,互斥量,信号量都是主要的互斥实现方式。互斥是因,同步是果。互斥是方法,同步是目的。最基本的互斥同步手段就是synchronized关键字,还有ReentrantLock

阻塞同步:互斥同步最主要的问题就是线程阻塞和唤醒带来的性能问题,属于悲观的并发策略,后来有了一个基于冲突检测的乐观并发策略,就是先进行操作,如果没有其他线程争用共享数据那就操作成功了,如果有争用产生了冲突,那就采取其他补偿措施(比如不断重试直到成功)这种为非阻塞同步,比如Unsafe类的CAS操作

 

 

其他知识

直接内存:

a、使用场景:一些jni场景中java需要调用native方法,会涉及java和native的数据传递。使用直接内存,相当于实现java和natvie内存共享,避免数据在java堆和native堆的来回复制。在netty和rpc框架中往往会用到直接内存,直接内存可以突破JVM内存限制,操作更多的物理内存

b、实现方式:直接内存是由本地内存分配的,因此直接内存受本机总内存大小的限制。底层是基于channel和buffer的NIO,可以使用native函数库直接分配堆外内存。java程序可以通过堆中的DirectByteBuffer对象进行堆外内存的管理和使用,它会在对象创建的时候就分配堆外内存。DirectByteBuffer主要是通过unsafe类的方法分配直接内存。直接内存是全局共享的,不受JVM堆内存大小限制,可能出现OutOfMemoryError异常

c、直接内存的回收方式:
自动回收:Java对堆外内存默认是自动回收的。在GC时会扫描是否有引用指向DirectByteBuffer对象。如没有,在回收DirectByteBuffer对象的同时且会回收其占用的堆外内存。只有在full gc时才会回收直接内存,有可能出现的现象就是直接内存占用了很多,而堆内没占用多少,导致还没触发full GC,很容易出现直接内存的OOM

手动回收:就是由开发手动调用DirectByteBuffer的cleaner的clean方法来释放空间。由于cleaner是private访问权限,所以需要使用反射来实现。Netty中的堆外内存池就是使用反射来实现手动回收方式进行回收的

d、直接内存的注意事项

直接内存的缺点是难以控制,只有在DirectByteBuffer回收掉之后才有机会被回收,因此如果这些对象大部分都移到了old区,但是一直没有触发Full GC,那么堆外内存会被耗尽了,因此为了避免这种悲剧的发生,通过-XX:MaxDirectMemorySize来指定最大的堆外内存大小,直接内存不足的时候会触发full gc,所以排查full gc的时候要考虑直接内存。如果没指定-XX:MaxDirectMemorySize,堆外内存大小默认和堆的最大值一样

posted @ 2022-09-23 15:11  MarkLeeBYR  阅读(50)  评论(0编辑  收藏  举报