线程并发库&JVM优化

  • 1. 线程池
    • 1.1 为什么用线程池?
    • 1.2 任务被添加进线程池的执行策略
    • 1.3 常见四种线程池
      • 1.3.1 可缓存线程池CachedThreadPool()
      • 1.3.2 定长线程池FixedThreadPool
      • 1.3.3 单线程池SingleThreadPool
      • 1.3.4 调度线程池ScheduledThreadPool
    • 1.4 ThreadPoolExecutor类构造器语法形式:
    • 1.5 在ThreadPoolExecutor类中几个重要的方法
    • 1.6 线程池中的最大线程数
  • 2. JVM优化
    • 2.1 JVM的作用
    • 2.2 JVM的组成
      • 2.2.1 类加载器子系统
        • 2.2.1.1 类加载的过程
        • 2.2.1.2 类与类加载器
        • 2.2.1.3 双亲委派机制
      • 2.2.2 运行时数据区
        • 2.2.2.1 程序计数器
        • 2.2.2.2 Java虚拟机栈
        • 2.2.2.3 本地方法栈
        • 2.2.2.4 堆内存
        • 2.2.2.5 元空间
      • 2.2.3 JVM内存溢出
        • 2.2.3.1 堆内存溢出
        • 2.2.3.2 虚拟机栈/本地方法栈溢出
        • 2.2.3.3 方法区溢出
        • 2.2.3.4 本机直接内存溢出
      • 2.2.4 JVM垃圾回收
        • 2.2.4.1 判断对象是否为垃圾
      • 2.2.4.2 常用垃圾回收算法
      • 2.2.4.3 选择垃圾收集的时间
      • 2.2.4.4 常见垃圾收集器
    • 2.3 JVM的优化

1. 线程池

1.1 为什么用线程池?

  1. 线程复用(创建/销毁线程伴随着系统开销,过于频繁的创建/销毁线程,会很大程度上影响处理效率)
  2. 控制并发数量(线程并发数量过多,抢占系统资源从而导致阻塞)
  3. 管理线程(对线程进行一些简单的管理)

1.2 任务被添加进线程池的执行策略

  • 线程数量未达到corePoolSize,则新建一个线程(核心线程)执行任务
  • 线程数量达到了corePoolSize,则将任务移入队列等待空闲线程将其取出去执行(通过getTask()方法从阻塞队列中获取等待的任务,如果队列中没有任务,getTask方法会被阻塞并挂起,不会占用cpu资源,整个getTask操作在自旋下完成)
  • 队列已满,新建线程(非核心线程)执行任务
  • 队列已满,总线程数又达到了maximumPoolSize,就会执行任务拒绝策略。

1.3 常见四种线程池

1.3.1 可缓存线程池CachedThreadPool()

  • 这种线程池内部没有核心线程,线程的数量是有没限制的。
  • 在创建任务时,若有空闲的线程时则复用空闲的线程,若没有则新建线程。
  • 没有工作的线程(闲置状态)在超过了60S还不做事,就会销毁。
  • 适用:执行很多短期异步的小程序或者负载较轻的服务器。

1.3.2 定长线程池FixedThreadPool

  • 该线程池的最大线程数等于核心线程数,所以在默认情况下,该线程池的线程不会因为闲置状态超时而被销毁
  • 如果当前线程数小于核心线程数,并且也有闲置线程的时候提交了任务,这时也不会去复用之前的闲置线程,会创建新的线程去执行任务。如果当前执行任务数大于了核心线程数,大于的部分就会进入队列等待。等着有闲置的线程来执行这个任务。
  • 适用:执行长期的任务,性能好很多。

1.3.3 单线程池SingleThreadPool

  • 有且仅有一个工作线程执行任务
  • 所有任务按照指定顺序执行,即遵循队列的入队出队规则。
  • 适用:一个任务一个任务执行的场景。

1.3.4 调度线程池ScheduledThreadPool

  • DEFAULT_KEEPALIVE_MILLIS就是默认10L,这里就是10秒。这个线程池有点像是CachedThreadPool和FixedThreadPool 结合了一下。
  • 不仅设置了核心线程数,最大线程数也是Integer.MAX_VALUE。
  • 这个线程池是上述4个中唯一一个有延迟执行和周期执行任务的线程池。
  • 适用:周期性执行任务的场景(定期的同步数据)

总结:除了new ScheduledThreadPool 的内部实现特殊一点之外,其它线程池内部都是基于ThreadPoolExecutor类(Executor的子类)实现的。

1.4 ThreadPoolExecutor类构造器语法形式:

ThreadPoolExecutor(corePoolSize,maxPoolSize,keepAliveTime,timeUnit,workQueue,threadFactory,handle);

方法参数:

  1. corePoolSize:核心线程数(最小存活的工作线程数量)
  2. maxPoolSize:最大线程数
  3. keepAliveTime:线程存活时间(在corePoreSize
  4. timeUnit:存活时间的时间单位
  5. workQueue:阻塞队列,用来保存等待被执行的任务(①synchronousQueue:这个队列比较特殊,它不会保存提交的任务,而是将直接新建一个线程来执行新来的任务;②LinkedBlockingQueue:基于链表的先进先出队列,如果创建时没有指定此队列大小,则默认为Integer.MAX_VALUE;③ArrayBlockingQueue:基于数组的先进先出队列,此队列创建时必须指定大小)
  6. threadFactory:线程工厂,主要用来创建线程;
  7. handler:表示当拒绝处理任务时的策略(①丢弃任务并抛出RejectedExecutionException异常;②丢弃任务,但是不抛出异常;③丢弃队列最前面的任务,然后重新尝试执行任务;④由调用线程处理该任务)

1.5 在ThreadPoolExecutor类中几个重要的方法

  1. execute()

    实际上是Executor中声明的方法,在ThreadPoolExecutor进行了具体的实现,这个方法是ThreadPoolExecutor的核心方法,通过这个方法可以向线程池提交一个任务,交由线程池去执行。

  2. submit()

    是在ExecutorService中声明的方法,在AbstractExecutorService就已经有了具体的实现,在ThreadPoolExecutor中并没有对其进行重写,这个方法也是用来向线程池提交任务的,实际上它还是调用的execute()方法,只不过它利用了Future来获取任务执行结果。

  3. shutdown()

    不会立即终止线程池,而是要等所有任务缓存队列中的任务都执行完后才终止,但再也不会接受新的任务。

  4. shutdownNow()

    立即终止线程池,并尝试打断正在执行的任务,并且清空任务缓存队列,返回尚未执行的任务。

  5. isTerminated()

    调用ExecutorService.shutdown方法的时候,线程池不再接收任何新任务,但此时线程池并不会立刻退出,直到添加到线程池中的任务都已经处理完成,才会退出。在调用shutdown方法后我们可以在一个死循环里面用isTerminated方法判断是否线程池中的所有线程已经执行完毕,如果子线程都结束了,我们就可以做关闭流等后续操作了。

1.6 线程池中的最大线程数

  • 一般说来,线程池的大小经验值应该这样设置:(其中N为CPU的个数)
  1. 如果是CPU密集型应用,则线程池大小设置为N+1
  2. 如果是IO密集型应用,则线程池大小设置为2N+1
  • 但是,IO优化中,这样的估算公式可能更适合:
  1. 最佳线程数目 = ((线程等待时间+线程CPU时间)/线程CPU时间 )* CPU数目
  2. 因为很显然,线程等待时间所占比例越高,需要越多线程。线程CPU时间所占比例越高,需要越少线程。

2. JVM优化

2.1 JVM的作用

JVM屏蔽了平台的不同,提供了统一的运行环境,让Java代码无需考虑平台的差异,运行在相同的环境中.

2.2 JVM的组成

[图片上传失败...(image-9e6488-1569804516966)]
大致分为以下组件:

  1. 类加载器子系统
  2. 运行时数据区
    方法区 堆 虚拟机栈 本地方法栈 程序计数器
  3. 执行引擎
  4. 本地方法库

2.2.1 类加载器子系统

2.2.1.1 类加载的过程

  1. 加载:找到字节码文件,读取到内存中.类的加载方式分为隐式加载和显示加载两种。隐式加载指的是程序在使用new关键词创建对象时,会隐式的调用类的加载器把对应的类加载到jvm中。显示加载指的是通过直接调用class.forName()方法来把所需的类加载到jvm中。
  2. 验证:验证此字节码文件是不是真的是一个字节码文件,毕竟后缀名可以随便改,而内在的身份标识是不会变的.在确认是一个字节码文件后,还会检查一系列的是否可运行验证,元数据验证,字节码验证,符号引用验证等.Java虚拟机规范对此要求很严格,在Java 7的规范中,已经有130页的描述验证过程的内容.
  3. 准备:为类中static修饰的变量分配内存空间并设置其初始值为0或null.可能会有人感觉奇怪,在类中定义一个static修饰的int,并赋值了123,为什么这里还是赋值0.因为这个int的123是在初始化阶段的时候才赋值的,这里只是先把内存分配好.但如果你的static修饰还加上了final,那么就会在准备阶段就会赋值.
  4. 解析:解析阶段会将java代码中的符号引用替换为直接引用.比如引用的是一个类,我们在代码中只有全限定名来标识它,在这个阶段会找到这个类加载到内存中的地址.
    初始化:如刚才准备阶段所说的,这个阶段就是对变量的赋值的阶段

2.2.1.2 类与类加载器

每一个类,都需要和它的类加载器一起确定其在JVM中的唯一性.换句话来说,不同类加载器加载的同一个字节码文件,得到的类都不相等.我们可以通过默认加载器去加载一个类,然后new一个对象,再通过自己定义的一个类加载器,去加载同一个字节码文件,拿前面得到的对象去instanceof,会得到的结果是false.

2.2.1.3 双亲委派机制

[图片上传失败...(image-7084f3-1569804516966)]

类加载器一般有4种,其中前3种是必然存在的

  1. 启动类加载器:加载\lib下的
  2. 扩展类加载器:加载\lib\ext下的
  3. 应用程序类加载器:加载Classpath下的
  4. 自定义类加载器(这种一般大企业才会有)

而双亲委派机制是如何运作的呢?

  1. 我们以应用程序类加载器举例,它在需要加载一个类的时候,不会直接去尝试加载,而是委托上级的扩展类加载器去加载,而扩展类加载器也是委托启动类加载器去加载.
  2. 启动类加载器在自己的搜索范围内没有找到这么一个类,表示自己无法加载,就再让扩展类加载器去加载,同样的,扩展类加载器在自己的搜索范围内找一遍,如果还是没有找到,就委托应用程序类加载器去加载.如果最终还是没找到,那就会直接抛出异常了.

而为什么要这么麻烦的从下到上,再从上到下呢?
这是为了安全着想,保证按照优先级加载.如果用户自己编写一个名为java.lang.Object的类,放到自己的Classpath中,没有这种优先级保证,应用程序类加载器就把这个当做Object加载到了内存中,从而会引发一片混乱.而凭借这种双亲委派机制,先一路向上委托,启动类加载器去找的时候,就把正确的Object加载到了内存中,后面再加载自行编写的Object的时候,是不会加载运行的.

2.2.2 运行时数据区

运行时数据区分为虚拟机栈,本地方法栈,堆区,方法区和程序计数器.

2.2.2.1 程序计数器

程序计数器是线程私有的,虽然名字叫计数器,但主要用途还是用来确定指令的执行顺序,比如循环,分支,跳转,异常捕获等.而JVM对于多线程的实现是通过轮流切换线程实现的,所以为了保证每个线程都能按正确顺序执行,将程序计数器作为线程私有.程序计数器是唯一一个JVM没有规定任何OOM的区块.

OOM:out of memory

程序计数器是一块非常小的内存空间,可以看做是当前线程执行字节码的行号指示器,每个线程都有一个独立的程序计数器,因此程序计数器是线程私有的一块空间,此外,程序计数器是Java虚拟机规定的唯一不会发生内存溢出的区域。

2.2.2.2 Java虚拟机栈

  1. Java虚拟机栈也是线程私有的,每个方法执行都会创建一个栈帧,局部变量就存放在栈帧中,还有一些其他的动态链接之类的.通常有两个错误会跟这个有关系,一个是StackOverFlowError,一个是OOM(OutOfMemoryError).前者是因为线程请求栈深度超出虚拟机所允许的范围,后者是动态扩展栈的大小的时候,申请不到足够的内存空间.而前者提到的栈深度,也就是刚才说到的每个方法会创建一个栈帧,栈帧从开始执行方法时压入Java虚拟机栈,执行完的时候弹出栈.当压入的栈帧太多了,就会报出这个StackOverflowError.
  2. 虚拟机会为每个线程分配一个虚拟机栈,每个虚拟机栈中都有若干个栈帧,每个栈帧中存储了局部变量表、操作数栈、动态链接、返回地址等。一个栈帧就对应Java代码中的一个方法,当线程执行到一个方法时,就代表这个方法对应的栈帧已经进入虚拟机栈并且处于栈顶的位置,每一个Java方法从被调用到执行结束,就对应了一个栈帧从入栈到出栈的过程。

2.2.2.3 本地方法栈

本地方法栈与虚拟机栈的区别是,虚拟机栈执行的是Java方法,本地方法栈执行的是本地方法(Native Method),其他基本上一致,在HotSpot中直接把本地方法栈和虚拟机栈合二为一,这里暂时不做过多叙述。

2.2.2.4 堆内存

确切来说JVM规范中方法区就是堆的一个逻辑分区,就是一个所有线程共享的,存放对象的区域,也是GC的主要区域.其中的分区分为新生代,老年代。新生代中又可以细分为一个Eden,两个Survivor区(From,To)。Eden中存放的是通过new或者newInstance方法创建出来的对象,绝大多数都是很短命的。正常情况下经历一次gc之后,存活的对象会转入到其中一个Survivor区,然后再经历默认15次的gc,就转入到老年代。

堆内存主要用于存放对象和数组,它是JVM管理的内存中最大的一块区域,堆内存和方法区都被所有线程共享,在虚拟机启动时创建。在垃圾收集的层面上来看,由于现在收集器基本上都采用分代收集算法,因此堆还可以分为新生代(YoungGeneration)和老年代(OldGeneration),新生代还可以分为Eden、From Survivor、To Survivor

堆是垃圾回收主要区域:

  1. 新生代 Eden、From Survivor、To Survivor 垃圾回收使用Minor GC
  2. 老年代垃圾回收使用Full GC

2.2.2.5 元空间

上面说到,jdk1.8中,已经不存在永久代(方法区),替代它的一块空间叫做“元空间”,和永久代类似,都是JVM规范对方法区的实现,但是元空间并不在虚拟机中,而是使用本地内存,元空间的大小仅受本地内存限制,但可以通过-XX:MetaspaceSize和-XX:MaxMetaspaceSize来指定元空间的大小

2.2.3 JVM内存溢出

2.2.3.1 堆内存溢出

堆内存中主要存放对象、数组等,只要不断地创建这些对象,并且保证GC Roots到对象之间有可达路径来避免垃圾收集回收机制清除这些对象,当这些对象所占空间超过最大堆容量时,就会产生OutOfMemoryError的异常。

看到 java.lang.OutOfMemoryError: Java heap space 的信息,说明在堆内存空间产生内存溢出的异常。

新产生的对象最初分配在新生代,新生代满后会进行一次Minor GC,如果Minor GC后空间不足会把该对象和新生代满足条件的对象放入老年代,老年代空间不足时会进行Full GC,之后如果空间还不足以存放新对象则抛出OutOfMemoryError异常。常见原因:内存中加载的数据过多如一次从数据库中取出过多数据;集合对对象引用过多且使用完后没有清空;代码中存在死循环或循环产生过多重复对象;堆内存分配不合理;网络连接问题、数据库问题等。

2.2.3.2 虚拟机栈/本地方法栈溢出

  1. StackOverflowError:当线程请求的栈的深度大于虚拟机所允许的最大深度,则抛出StackOverflowError,简单理解就是虚拟机栈中的栈帧数量过多(一个线程嵌套调用的方法数量过多)时,就会抛出StackOverflowError异常。最常见的场景就是方法无限递归调用
  2. OutOfMemoryError:如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError。

在线程较少的时候,某个线程请求深度过大,会报StackOverflow异常,解决这种问题可以适当加大栈的深度(增加栈空间大小),也就是把-Xss的值设置大一些,但一般情况下是代码问题的可能性较大;在虚拟机产生线程时,无法为该线程申请栈空间了,会报OutOfMemoryError异常,解决这种问题可以适当减小栈的深度,也就是把-Xss的值设置小一些,每个线程占用的空间小了,总空间一定就能容纳更多的线程,但是操作系统对一个进程的线程数有限制,经验值在3000~5000左右。在jdk1.5之前-Xss默认是256k,jdk1.5之后默认是1M,这个选项对系统硬性还是蛮大的,设置时要根据实际情况,谨慎操作。

2.2.3.3 方法区溢出

  1. 方法区主要用于存储虚拟机加载的类信息、常量、静态变量,以及编译器编译后的代码等数据,所以方法区溢出的原因就是没有足够的内存来存放这些数据。
  2. 由于在jdk1.6之前字符串常量池是存在于方法区中的,所以基于jdk1.6之前的虚拟机,可以通过不断产生不一致的字符串(同时要保证和GC Roots之间保证有可达路径)来模拟方法区的OutOfMemoryError异常;但方法区还存储加载的类信息,所以基于jdk1.7的虚拟机,可以通过动态不断创建大量的类来模拟方法区溢出。

2.2.3.4 本机直接内存溢出

本机直接内存(DirectMemory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但Java中用到NIO相关操作时(比如ByteBuffer的allocteDirect方法申请的是本机直接内存),也可能会出现内存溢出的异常。

2.2.4 JVM垃圾回收

垃圾回收,就是通过垃圾收集器把内存中没用的对象清理掉。

垃圾回收涉及到的内容有:

  1. 判断对象是否已死;
  2. 选择垃圾收集算法;
  3. 选择垃圾收集的时间;
  4. 选择适当的垃圾收集器清理垃圾(已死的对象)。

2.2.4.1 判断对象是否为垃圾

  1. 引用计数算法
    • 给每一个对象添加一个引用计数器,每当有一个地方引用它时,计数器值加1;每当有一个地方不再引用它时,计数器值减1,这样只要计数器的值不为0,就说明还有地方引用它,它就不是无用的对象。如下图,对象2有1个引用,它的引用计数器值为1,对象1有两个地方引用,它的引用计数器值为2 。
    • 这种方法看起来非常简单,但目前许多主流的虚拟机都没有选用这种算法来管理内存,原因就是当某些对象之间互相引用时,无法判断出这些对象是否已死,对象1和对象2都没有被堆外的变量引用,而是被对方互相引用,这时他们虽然没有用处了,但是引用计数器的值仍然是1,无法判断他们是死对象,垃圾回收器也就无法回收。
  2. 可达性分析算法
    • 了解可达性分析算法之前先了解一个概念——GC Roots,垃圾收集的起点,可以作为GC Roots的有虚拟机栈中本地变量表中引用的对象、方法区中静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI(Native方法)引用的对象。
    • 当一个对象到GC Roots没有任何引用链相连(GC Roots到这个对象不可达)时,就说明此对象是不可用的,是死对象。
    • 被判了死刑的对象(object5、object6、object7)并不是必死无疑,还有挽救的余地。进行可达性分析后对象和GC Roots之间没有引用链相连时,对象将会被进行一次标记,接着会判断如果对象没有覆盖Object的finalize()方法或者finalize()方法已经被虚拟机调用过,那么它们就会被行刑(清除);如果对象覆盖了finalize()方法且还没有被调用,则会执行finalize()方法中的内容,所以在finalize()方法中如果重新与GC Roots引用链上的对象关联就可以拯救自己,但是一般不建议这么做.
  3. 方法区回收
    • 上面说的都是对堆内存中对象的判断,方法区中主要回收的是废弃的常量和无用的类
    • 判断常量是否废弃可以判断是否有地方引用这个常量,如果没有引用则为废弃的常量。
    • 判断类是否废弃需要同时满足如下条件:
      • 该类所有的实例已经被回收(堆中不存在任何该类的实例)
      • 加载该类的ClassLoader已经被回收
      • 该类对应的java.lang.Class对象在任何地方没有被引用(无法通过反射访问该类的方法)

2.2.4.2 常用垃圾回收算法

常用的垃圾回收算法有三种:标记-清除算法、复制算法、标记-整理算法。

  1. 标记-清除算法:
    • 分为标记和清除两个阶段,首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象
    • 缺点:标记和清除两个过程效率都不高;标记清除之后会产生大量不连续的内存碎片。
  2. 复制算法:
    • 把内存分为大小相等的两块,每次存储只用其中一块,当这一块用完了,就把存活的对象全部复制到另一块上,同时把使用过的这块内存空间全部清理掉,往复循环
    • 缺点:实际可使用的内存空间缩小为原来的一半
  3. 标记-整理算法:
    • 先对可用的对象进行标记,然后所有被标记的对象向一段移动,最后清除可用对象边界以外的内存
  4. 分代收集算法:
    • 把堆内存分为新生代和老年代,新生代又分为Eden区、From Survivor和To Survivor。一般新生代中的对象基本上都是朝生夕灭的,每次只有少量对象存活,因此采用复制算法,只需要复制那些少量存活的对象就可以完成垃圾收集;老年代中的对象存活率较高,就采用标记-清除和标记-整理算法来进行回收。
    • 在这些区域的垃圾回收大概有如下几种情况:
      • 新生代使用时minor gc
      • 老年代使用的full gc
  • 大多数情况下,新的对象都分配在Eden区,当Eden区没有空间进行分配时,将进行一次Minor GC,清理Eden区中的无用对象。清理后,Eden和From Survivor中的存活对象如果小于To Survivor的可用空间则进入To Survivor,否则直接进入老年代);Eden和From Survivor中还存活且能够进入To Survivor的对象年龄增加1岁(虚拟机为每个对象定义了一个年龄计数器,每执行一次Minor GC年龄加1),当存活对象的年龄到达一定程度(默认15岁)后进入老年代,可以通过-XX:MaxTenuringThreshold来设置年龄的值。
  • 当进行了Minor GC后,Eden还不足以为新对象分配空间(那这个新对象肯定很大),新对象直接进入老年代。
  • 占To Survivor空间一半以上且年龄相等的对象,大于等于该年龄的对象直接进入老年代,比如Survivor空间是10M,有几个年龄为4的对象占用总空间已经超过5M,则年龄大于等于4的对象都直接进入老年代,不需要等到MaxTenuringThreshold指定的岁数。
  • 在进行Minor GC之前,会判断老年代最大连续可用空间是否大于新生代所有对象总空间,如果大于,说明Minor GC是安全的,否则会判断是否允许担保失败,如果允许,判断老年代最大连续可用空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则执行Minor GC,否则执行Full GC。
  • 当在java代码里直接调用System.gc()时,会建议JVM进行Full GC,但一般情况下都会触发Full GC,一般不建议使用,尽量让虚拟机自己管理GC的策略。
  • 永久代(方法区)中用于存放类信息,jdk1.6及之前的版本永久代中还存储常量、静态变量等,当永久代的空间不足时,也会触发Full GC,如果经过Full GC还无法满足永久代存放新数据的需求,就会抛出永久代的内存溢出异常。
  • 大对象(需要大量连续内存的对象)例如很长的数组,会直接进入老年代,如果老年代没有足够的连续大空间来存放,则会进行Full GC。

Minor GC和Full GC

  • 在说这两种回收的区别之前,我们先来说一个概念,“Stop-The-World”。
  • 如字面意思,每次垃圾回收的时候,都会将整个JVM暂停,回收完成后再继续。如果一边增加废弃对象,一边进行垃圾回收,完成工作似乎就变得遥遥无期了。
  • 而一般来说,我们把新生代的回收称为Minor GC,Minor意思是次要的,新生代的回收一般回收很快,采用复制算法,造成的暂停时间很短。而Full GC一般是老年代的回收,并伴随至少一次的Minor GC,新生代和老年代都回收,而老年代采用标记-整理算法,这种GC每次都比较慢,造成的暂停时间比较长,通常是Minor GC时间的10倍以上。
  • 所以很明显,我们需要尽量通过Minor GC来回收内存,而尽量少的触发Full GC。毕竟系统运行一会儿就要因为GC卡住一段时间,再加上其他的同步阻塞,整个系统给人的感觉就是又卡又慢。

2.2.4.3 选择垃圾收集的时间

  • 当程序运行时,各种数据、对象、线程、内存等都时刻在发生变化,当下达垃圾收集命令后就立刻进行收集吗?肯定不是。这里来了解两个概念:安全点(safepoint)和安全区(safe region)。
  • 安全点:从线程角度看,安全点可以理解为是在代码执行过程中的一些特殊位置,当线程执行到安全点的时候,说明虚拟机当前的状态是安全的,如果有需要,可以在这里暂停用户线程。当垃圾收集时,如果需要暂停当前的用户线程,但用户线程当时没在安全点上,则应该等待这些线程执行到安全点再暂停。举个例子,妈妈在扫地,儿子在吃西瓜(瓜皮会扔到地上),妈妈扫到儿子跟前时,儿子说:“妈妈等一下,让我吃完这块再扫。”儿子吃完这块西瓜把瓜皮扔到地上后就是一个安全点,妈妈可以继续扫地(垃圾收集器可以继续收集垃圾)。理论上,解释器的每条字节码的边界上都可以放一个安全点,实际上,安全点基本上以“是否具有让程序长时间执行的特征”为标准进行选定。
  • 安全区:安全点是相对于运行中的线程来说的,对于如sleep或blocked等状态的线程,收集器不会等待这些线程被分配CPU时间,这时候只要线程处于安全区中,就可以算是安全的。安全区就是在一段代码片段中,引用关系不会发生变化,可以看作是被扩展、拉长了的安全点。还以上面的例子说明,妈妈在扫地,儿子在吃西瓜(瓜皮会扔到地上),妈妈扫到儿子跟前时,儿子说:“妈妈你继续扫地吧,我还得吃10分钟呢!”儿子吃瓜的这段时间就是安全区,妈妈可以继续扫地(垃圾收集器可以继续收集垃圾)。 一段连续的安全点

2.2.4.4 常见垃圾收集器

现在常见的垃圾收集器有如下几种:

  1. 新生代收集器:Serial、ParNew、Parallel Scavenge
  2. 老年代收集器:Serial Old、CMS、Parallel Old

堆内存垃圾收集器:G1

  1. Serial 收集器
  2. ParNew 收集器
  3. Parallel Scavenge 收集器
  4. Serial Old收集器
  5. CMS(Concurrent Mark Sweep) 收集器
  6. Parallel Old 收集器
  7. G1 收集器

适用场景:要求尽可能可控GC停顿时间;内存占用较大的应用。可以用-XX:+UseG1GC使用G1收集器,jdk9默认使用G1收集器。

2.3 JVM的优化

JVM调优目标:使用较小的内存占用来获得较高的吞吐量或者较低的延迟。

程序在上线前的测试或运行中有时会出现一些大大小小的JVM问题,比如cpu load过高、请求延迟、tps降低等,甚至出现内存泄漏(每次垃圾收集使用的时间越来越长,垃圾收集频率越来越高,每次垃圾收集清理掉的垃圾数据越来越少)、内存溢出导致系统崩溃,因此需要对JVM进行调优,使得程序在正常运行的前提下,获得更高的用户体验和运行效率。

  • 内存占用:程序正常运行需要的内存大小。
  • 延迟:由于垃圾收集而引起的程序停顿时间。
  • 吞吐量:用户程序运行时间占用户程序和垃圾收集占用总时间的比值。

当然,和CAP原则一样,同时满足一个程序内存占用小、延迟低、高吞吐量是不可能的,程序的目标不同,调优时所考虑的方向也不同,在调优之前,必须要结合实际场景,有明确的的优化目标,找到性能瓶颈,对瓶颈有针对性的优化,最后进行测试,通过各种监控工具确认调优后的结果是否符合目标。

调优可以依赖、参考的数据有系统运行日志、堆栈错误信息、gc日志、线程快照、堆转储快照等。

你可能感兴趣的:(线程并发库&JVM优化)