欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

Java并发(二十):线程本地变量ThreadLocal

程序员文章站 2022-04-18 20:38:24
一、简介 Java GC(Garbage Collection,垃圾回收)机制,是Java与C++/C的主要区别之一 在C++/C语言中,程序员必须小心谨慎地处理每一项内存分配,且内存使用完后必须手工释放曾经占用的内存空间。当内存释放不够完全时,即存在分配但永不释放的内存块,就会引起内存泄漏,严重时 ......

一、简介

java gc(garbage collection,垃圾回收)机制,是java与c++/c的主要区别之一

  在c++/c语言中,程序员必须小心谨慎地处理每一项内存分配,且内存使用完后必须手工释放曾经占用的内存空间。当内存释放不够完全时,即存在分配但永不释放的内存块,就会引起内存泄漏,严重时甚至导致程序瘫痪。

  java 语言的一大特点就是可以进行自动垃圾回收处理,而无需开发人员过于关注系统资源。

  垃圾回收机制对jvm中的内存进行标记,并确定哪些内存需要回收,根据一定的回收策略,自动的回收内存,永不停息(nerver stop)的保证jvm中的内存空间,防止出现内存泄露和溢出问题。

问题:

  1、垃圾回收并不会按照程序员的要求,随时进行gc。

  2、垃圾回收并不会及时的清理内存,尽管有时程序需要额外的内存。

  3、程序员不能对垃圾回收进行控制。

作为java程序员我们很难去控制jvm的内存回收,只能根据它的原理去适应,尽量提高程序的性能。

 二、垃圾回收过程

minor gc/young gc:只收集新生代的gc  触发条件:eden区满时

major gc/full gc:收集老年代、永久带(方法区)(根据垃圾收集器不同可能收集新生代)

触发条件: 

  (1)调用system.gc()时,系统建议执行full gc,但是不必然执行

  (2)老年代空间不足

  (3)方法去空间不足

  (4)通过minor gc后进入老年代的平均大小大于老年代的可用内存

  (5)由eden区、from space区向to space区复制时,对象大小大于to space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

新生代垃圾回收过程

算法:“停止-复制”算法进行清理

1.创建对象分配到eden区

2.第一次eden区满,执行minor gc,将存活对象复制到一个存活区survivor0,清空eden区

  *此时,survivor1是空白的,两个survivor总有一个是空白的

  *eden区是连续的内存空间,因此在其上分配内存极快

3.程序继续创建对象分配到eden区,eden区满执行minor gc,eden和survivor0中存活的对象复制到survivor1,清空eden和survivor0

  *如果对象比较大,比如长字符串或大数组,young空间不足,则大对象会直接分配到老年代上

  *大对象可能触发提前gc,应少用,更应避免使用短命的大对象

  *用-xx:pretenuresizethreshold来控制直接升入老年代的对象大小,大于这个值的对象会直接分配在老年代上

  *eden和survivor0存活对象占用空间超过survivor1,多余对象进入老年代

  *由于绝大部分的对象都是短命的,甚至存活不到survivor中,所以,eden区与survivor的比例较大,hotspot默认是 8:1。

    如果一次回收中,survivor+eden中存活下来的内存超过了10%,则需要将一部分对象分配到 老年代。

    用-xx:survivorratio参数来配置eden区域survivor区的容量比值,默认是8,代表eden:survivor1:survivor2=8:1:1.

4.程序继续创建对象分配到eden区,eden区满执行minor gc,eden和survivor1中存活的对象复制到survivor0,清空eden和survivor1(反复3 4)

5.当两个存活区切换了一定次数之后,仍然存活的对象,将被复制到老年代

  *hotspot虚拟机默认15次,用-xx:maxtenuringthreshold控制

老年代、永久代垃圾回收过程

算法:老年代存储的对象比年轻代多得多,而且不乏大对象,对老年代进行内存清理时,如果使用停止-复制算法,则相当低效。

     一般,老年代用的算法是标记-整理算法,即:标记出仍然存活的对象(存在引用的),将所有存活的对象向一端移动,以保证内存的连续。

1.调用system.gc()  或老年代空间不足  或永久代空间不足时,执行major gc

2.老年代对象引用新生代对象的情况,如果需要执行young gc,则可能需要查询整个老年代以确定是否可以清理回收,这显然是低效的。

  解决的方法是,年老代中维护一个512 byte的块——”card table“,所有老年代对象引用新生代对象的记录都记录在这里。

  young gc时,只要查这里即可,不用再去查全部老年代,因此性能大大提高。

3.minor gc与full gc:

minor gc时,检查进入老年代的大小是否大于老年代的剩余空间大小

大于:直接触发一次full gc

不大于:查看是否设置了-xx:+handlepromotionfailure(允许担保失败)

    允许:只会进行minorgc,此时可以容忍内存分配失败

    不允许:进行full gc

(如果设置-xx:+handle promotionfailure不允许担保失败,则触发minorgc就会同时触发full gc,哪怕老年代还有很多内存,所以,最好不要这样做)

4.方法区(永久代)回收:

  常量池中的常量:没有引用了就可以被回收。(jdk7+以后,字符串常量被移动到堆中)

  无用的类信息:

    (1)类的所有实例都已经被回收

    (2)加载类的classloader已经被回收

    (3)类对象的class对象没有被引用(即没有通过反射引用该类的地方)

永久代的回收并不是必须的,可以通过参数来设置是否对类进行回收。
hotspot提供-xnoclassgc进行控制,使用-verbose,-xx:+traceclassloading、-xx:+traceclassunloading可以查看类加载和卸载信息
-verbose、-xx:+traceclassloading可以在product版hotspot中使用;-xx:+traceclassunloading需要fastdebug版hotspot支持

三、判断对象是否存活

通过引用计数法/可达性分析法来判定对象是否存活

1、引用计数法

给对象中添加一个引用计数器,每当一个地方引用这个对象时,计数器值+1;当引用失效时,计数器值-1。任何时刻计数值为0的对象就是不可能再被使用的。

java中却没有使用这种算法,因为这种算法很难解决对象之间相互引用的情况。

2、可达性分析法

这个算法的基本思想是通过一系列称为“gc roots”的对象作为起始点,从这些节点向下搜索,搜索所走过的路径称为引用链,

当一个对象到gc roots没有任何引用链(即gc roots到对象不可达)时,则证明此对象是不可用的。

可以作为gcroots的对象包括下面几种:

  (1) 虚拟机栈(栈帧中的局部变量区,也叫做局部变量表)中引用的对象。

  (2) 方法区中的类静态属性引用的对象。

  (3) 方法区中常量引用的对象。

  (4) 本地方法栈中jni(native方法)引用的对象。

Java并发(二十):线程本地变量ThreadLocal

如图:obj8、obj9、obj10都没有到gcroots对象的引用链,即便obj9和obj10之间有引用链,他们还是会被当成垃圾处理,可以进行回收。

3、finalize方法

对于可达性分析算法而言,若要判断一个对象死亡,需要经历两次标记阶段。

第一次标记:对象在进行可达性分析后发现没有与gcroots相连的引用链,则该对象被第一次标记并进行一次筛选,筛选条件为是否有必要执行该对象的finalize方法

  若对象没有覆盖finalize方法或者该finalize方法是否已经被虚拟机执行过了,则均视作不必要执行该对象的finalize方法,即该对象将会被回收。

  若对象覆盖了finalize方法并且该finalize方法并没有被执行过,这个对象会被放置在一个叫f-queue的队列中,之后会由虚拟机自动建立的、优先级低的finalizer线程去执行

第二次标记:对f-queue中对象进行第二次标记

  如果对象在finalize方法中拯救了自己,即关联上了gcroots引用链,那么在第二次标记的时候该对象将从“即将回收”的集合中移除

  如果对象还是没有拯救自己,那就会被回收

如下代码演示了一个对象如何在finalize方法中拯救了自己,然而,它只能拯救自己一次,第二次就被回收了。具体代码如下:

 

package com.demo;

/*
 * 此代码演示了两点:
 * 1.对象可以再被gc时自我拯救
 * 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
 * */
public class finalizeescapegc {
    
    public string name;
    public static finalizeescapegc save_hook = null;

    public finalizeescapegc(string name) {
        this.name = name;
    }

    public void isalive() {
        system.out.println("yes, i am still alive :)");
    }
    
    @override
    protected void finalize() throws throwable {
        super.finalize();
        system.out.println("finalize method executed!");
        system.out.println(this);
        finalizeescapegc.save_hook = this;
    }

    @override
    public string tostring() {
        return name;
    }

    public static void main(string[] args) throws interruptedexception {
        save_hook = new finalizeescapegc("leesf");
        system.out.println(save_hook);
        // 对象第一次拯救自己
        save_hook = null;
        system.out.println(save_hook);
        system.gc();
        // 因为finalize方法优先级很低,所以暂停0.5秒以等待它
        thread.sleep(500);
        if (save_hook != null) {
            save_hook.isalive();
        } else {
            system.out.println("no, i am dead : (");
        }

        // 下面这段代码与上面的完全相同,但是这一次自救却失败了
        // 一个对象的finalize方法只会被调用一次
        save_hook = null;
        system.gc();
        // 因为finalize方法优先级很低,所以暂停0.5秒以等待它
        thread.sleep(500);
        if (save_hook != null) {
            save_hook.isalive();
        } else {
            system.out.println("no, i am dead : (");
        }
    }
}
运行结果如下:
leesf null finalize method executed! leesf yes, i am still alive :) no, i am dead : (

四、四种引用状态

1、强引用

代码中普遍存在的类似"object obj = new object()"这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。

2、软引用

描述有些还有用但并非必需的对象。在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围进行二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。java中的类softreference表示软引用。

3、弱引用

描述非必需对象。被弱引用关联的对象只能生存到下一次垃圾回收之前,垃圾收集器工作之后,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。java中的类weakreference表示弱引用。

4、虚引用

这个引用存在的唯一目的就是在这个对象被收集器回收时收到一个系统通知,被虚引用关联的对象,和其生存时间完全没关系。java中的类phantomreference表示虚引用。

Java并发(二十):线程本地变量ThreadLocal

threadlocal中有强引用和弱引用的应用,并且有内存泄漏风险。java并发(二十):线程本地变量threadlocal

五、垃圾收集算法

1、标记-清除(mark-sweep)算法

首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象。

不足:

  从效率的角度讲,标记和清除两个过程的效率都不高;

  从空间的角度讲,标记清除后会产生大量不连续的内存碎片。

  (内存碎片太多可能会导致以后程序运行过程中在需要分配较大对象时,无法找到足够的连续内存而不得不提前触发一次垃圾收集动作)

 Java并发(二十):线程本地变量ThreadLocal

2、复制(copying)算法

将可用的内存分为两块,每次只用其中一块,当这一块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已经使用过的内存空间一次性清理掉。

优点:这样每次只需要对整个半区进行内存回收,内存分配时也不需要考虑内存碎片等复杂情况,只需要移动指针,按照顺序分配即可。

缺点:对象存活率较高的场景下要进行大量的复制操作,效率很低。万一对象100%存活,那么需要有额外的空间进行分配担保。(因此不适合老年代)

hotspot 虚拟机采用这种算法来回收新生代。

Java并发(二十):线程本地变量ThreadLocal

3、标记-整理(mark-compact)算法

过程与标记-清除算法一样,不过不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉边界以外的内存。

 Java并发(二十):线程本地变量ThreadLocal

 

 总结:大批对象死去、少量对象存活的(新生代),使用复制算法,复制成本低;对象存活率高、没有额外空间进行分配担保的(老年代),采用标记-清理算法或者标记-整理算法。

六、垃圾收集器

hotspo虚拟机垃圾收集器如图:

Java并发(二十):线程本地变量ThreadLocal

1.如果两个收集器之间存在连线,那说明它们可以搭配使用。

2.虚拟机所处的区域说明它是属于新生代收集器还是老年代收集器。

3.没有最好的垃圾收集器,更加没有万能的收集器,只能选择对具体应用最合适的收集器。

1、serial收集器

新生代收集器,使用停止复制算法,使用一个线程进行gc,串行,其它工作线程暂停。

单线程串行:进行垃圾收集时必须暂停其他线程的所有工作,直到它收集结束为止。

在用户不可见的情况下要把用户正常工作的线程全部停掉(stop the world)。

不过实际上到目前为止,serial收集器依然是虚拟机运行在client模式下的默认新生代收集器,因为它简单而高效。用户桌面应用场景中,分配给虚拟机管理的内存一般来说不会很大,收集几十兆甚至一两百兆的新生代停顿时间在几十毫秒最多一百毫秒,只要不是频繁发生,这点停顿是完全可以接受的。

使用-xx:+useserialgc可以使用serial+serial old模式运行进行内存回收(这也是虚拟机在client模式下运行的默认值)

Java并发(二十):线程本地变量ThreadLocal

2、parnew收集器

新生代收集器,使用停止复制算法,用多个线程进行gc(serial收集器的多线程版),并行,其它工作线程暂停,关注缩短垃圾收集时用户线程的停顿时间。

server模式下的虚拟机首选的新生代收集器,因为除了serial收集器外,目前只有它能与cms收集器配合工作(看图)。

使用-xx:+useparnewgc开关来控制使用parnew+serial old收集器组合收集内存;使用-xx:parallelgcthreads来设置执行内存回收的线程数。

Java并发(二十):线程本地变量ThreadLocal

3、parallel scavenge收集器

新生代收集器,使用停止复制算法,多线程,并行,关注cpu吞吐量。

吞吐量=运行用户代码的时间/总时间,比如:jvm运行100分钟,其中运行用户代码99分钟,垃圾收集1分钟,则吞吐量是99%。反映cpu使用效率。

cms等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而parallel scavenge收集器的目标则是打到一个可控制的吞吐量。

停顿时间短适合需要与用户交互的程序,良好的响应速度能提升用户体验;高吞吐量则可以高效率利用cpu时间,尽快完成运算任务,主要适合在后台运算而不需要太多交互的任务。

使用-xx:+useparallelgc开关控制使用parallel scavenge+serial old收集器组合回收垃圾(这也是在server模式下的默认值);使用-xx:gctimeratio来设置用户执行时间占总时间的比例,默认99,即1%的时间用来进行垃圾回收。使用-xx:maxgcpausemillis设置gc的最大停顿时间(这个参数只对parallel scavenge有效)

用开关参数-xx:+useadaptivesizepolicy可以进行动态控制,如自动调整eden/survivor比例,老年代对象年龄,新生代大小等,这个参数在parnew下没有。

Java并发(二十):线程本地变量ThreadLocal

4、serial old收集器——serial收集器的老年代版本

5、parallel old收集器——parallel scavenge收集器的老年代版本

使用-xx:+useparalleloldgc开关控制使用parallel scavenge +parallel old组合收集器进行收集。

6、cms收集器

老年代收集器,使用标记清除算法,多线程,优点是并发收集(用户线程可以和gc线程同时工作),停顿小。以获取最短回收停顿时间为目标

使用-xx:+useconcmarksweepgc进行parnew+cms+serial old进行内存回收,优先使用parnew+cms,当用户线程内存不足时,采用备用方案serial old收集(悲观full gc)。

过程:

(1)初始标记,标记gcroots能直接关联到的对象,stop the world,时间很短。

(2)并发标记,标记gcroots可达的对象,和应用线程并发执行,不需要用户停顿,时间很长。

(3)重新标记,修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,stop the world,时间较初始标记阶段长。

(4)并发清除,回收内存空间,和应用线程并发执行,时间很长。

缺点:

(1)需要消耗额外的cpu和内存资源,在cpu和内存资源紧张,cpu较少时,会加重系统负担(cms默认启动线程数为(cpu数量+3)/4)。

(2)在并发收集过程中,用户线程仍然在运行,所以可能产生“浮动垃圾”,本次无法清理,只能下一次full gc才清理。

    因此在gc期间,需要预留足够的内存给用户线程使用。所以使用cms的收集器并不是老年代满了才触发full gc,而是在使用了一大半的时候就要进行full gc。

  (默认68%,即2/3,使用-xx:cmsinitiatingoccupancyfraction来设置)

   如果预留的用户线程内存不够,则会触发concurrent mode failure,此时将触发备用方案:使用serial old 收集器进行收集,但这样停顿时间就长了。

   如果用户线程消耗内存不是特别大,可以适当调高-xx:cmsinitiatingoccupancyfraction以降低gc次数,提高性能。

(3)cms采用的是标记清除算法,会导致内存碎片的产生

   可以使用-xx:+usecmscompactatfullcollection来设置是否在full gc之后进行碎片整理

   用-xx:cmsfullgcsbeforecompaction来设置在执行多少次不压缩的full gc之后,来一次带压缩的full gc

java系列笔记(3) - java 内存区域和gc机制中对cms做了详细介绍

7、g1收集器

g1是目前技术发展的最前沿成果之一,hotspot开发团队赋予它的使命是未来可以替换掉jdk1.5中发布的cms收集器。与其他gc收集器相比,g1收集器有以下特点:

(1)并行+并发。使用多个cpu来缩短stop the world停顿时间,与用户线程并发执行。

(2)分代收集。独立管理整个堆,但是能够采用不同的方式去处理新创建对象和已经存活了一段时间、熬过多次gc的旧对象,以获取更好的收集效果。

(3)空间整合。基于标记 - 整理算法,无内存碎片产生。

(4)可预测的停顿。能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为m毫秒的时间片段内,消耗在垃圾收集上的时间不得超过n毫秒。

  在g1之前的垃圾收集器,收集的范围都是整个新生代或者老年代,而g1不再是这样。使用g1收集器时,java堆的内存布局与其他收集器有很大差别,它将整个java堆划分为多个大小相等的独立区域(region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分(可以不连续)region的集合。

七、gc日志

[gc [defnew: 310k->194k(2368k), 0.0269163 secs] 310k->194k(7680k), 0.0269513 secs] [times: user=0.00 sys=0.00, real=0.03 secs] 
[gc [defnew: 2242k->0k(2368k), 0.0018814 secs] 2242k->2241k(7680k), 0.0019172 secs] [times: user=0.00 sys=0.00, real=0.00 secs] 
[full gc (system) [tenured: 2241k->193k(5312k), 0.0056517 secs] 4289k->193k(7680k), [perm : 2950k->2950k(21248k)], 0.0057094 secs] [times: user=0.00 sys=0.00, real=0.00 secs] 
heap
 def new generation   total 2432k, used 43k [0x00000000052a0000, 0x0000000005540000, 0x0000000006ea0000)
  eden space 2176k,   2% used [0x00000000052a0000, 0x00000000052aaeb8, 0x00000000054c0000)
  from space 256k,   0% used [0x00000000054c0000, 0x00000000054c0000, 0x0000000005500000)
  to   space 256k,   0% used [0x0000000005500000, 0x0000000005500000, 0x0000000005540000)
 tenured generation   total 5312k, used 193k [0x0000000006ea0000, 0x00000000073d0000, 0x000000000a6a0000)
   the space 5312k,   3% used [0x0000000006ea0000, 0x0000000006ed0730, 0x0000000006ed0800, 0x00000000073d0000)
 compacting perm gen  total 21248k, used 2982k [0x000000000a6a0000, 0x000000000bb60000, 0x000000000faa0000)
   the space 21248k,  14% used [0x000000000a6a0000, 0x000000000a989980, 0x000000000a989a00, 0x000000000bb60000)
no shared spaces configured.

1、日志的开头“gc”、“full gc”表示这次垃圾收集的停顿类型,而不是用来区分新生代gc还是老年代gc的。如果有full,则说明本次gc停止了其他所有工作线程(stop-the-world)。看到full gc的写法是“full gc(system)”,这说明是调用system.gc()方法所触发的gc。

2、“gc”中接下来的“[defnew”表示gc发生的区域,这里显示的区域名称与使用的gc收集器是密切相关的,例如上面样例所使用的serial收集器中的新生代名为“default new generation”,所以显示的是“[defnew”。如果是parnew收集器,新生代名称就会变为“[parnew”,意为“parallel new generation”。如果采用parallel scavenge收集器,那它配套的新生代称为“psyounggen”,老年代和永久代同理,名称也是由收集器决定的。

3、后面方括号内部的“310k->194k(2368k)”、“2242k->0k(2368k)”,指的是该区域已使用的容量->gc后该内存区域已使用的容量(该内存区总容量)。方括号外面的“310k->194k(7680k)”、“2242k->2241k(7680k)”则指的是gc前java堆已使用的容量->gc后java堆已使用的容量(java堆总容量)

4、再往后“0.0269163 secs”表示该内存区域gc所占用的时间,单位是秒。最后的“[times: user=0.00 sys=0.00 real=0.03 secs]”则更具体了,user表示用户态消耗的cpu时间、内核态消耗的cpu时间、操作从开始到结束经过的墙钟时间。后面两个的区别是,墙钟时间包括各种非运算的等待消耗,比如等待磁盘i/o、等待线程阻塞,而cpu时间不包括这些耗时,但当系统有多cpu或者多核的话,多线程操作会叠加这些cpu时间,所以如果看到user或sys时间超过real时间是完全正常的。

5、“heap”后面就列举出堆内存目前各个年代的区域的内存情况。

 

 

参考:

1、《java编程思想》,第5章;

2、《java深度历险》,java垃圾回收机制与引用类型;

3、《深入理解java虚拟机:jvm高级特效与最佳实现》,第2-3章;

4、java之美[从菜鸟到高手演变]之jvm内存管理及垃圾回收

5、java垃圾回收(gc)机制详解

6、java系列笔记(3) - java 内存区域和gc机制