一 、 线程池
1. 线程池的作用
(1) 为什么使用连接池
-
统一管理数据库连接
-
通过设置最大连接数限制数据库的最大并发
-
防止频繁的开启与关闭连接造成资源浪费
(2) 为什么使用线程池
-
创建/销毁线程伴随着系统开销,过于频繁的创建/销毁线程,会很大程度上影响处理效率。(线程复用)
-
线程并发数量过多,抢占系统资源从而导致阻塞。(控制并发数量)
-
对线程进行一些简单的管理。(管理线程)
2. 线程池的原理
一个队列存放任务
一个队列存放线程
自己实现线程池如何设计
- 线程复用:实现线程复用的原理应该就是要保持线程处于存活状态(就绪,运行或阻塞)
将开启的线程放入一个队列中,并保持线程出于存活状态,可以使用wait阻塞线程
- 控制并发数量:(核心线程和最大线程数控制)
核心线程数,最大线程数
- 管理线程(设置线程的状态)
tag标识,如果标识为true,则中断此线程
3. 线程池如何工作
corePoolSize: 核心线程数
getTask(): 从阻塞队列中获取等待的线程任务
maximumPoolSize: 线程池的最大线程数
-
线程数量未达到corePoolSize,则新建一个线程(核心线程)执行任务
-
线程数量达到了corePoolSize,则将任务移入队列等待空闲线程将其取出去执行(通过getTask()方法从阻塞队列中获取等待的任务,如果队列中没有任务,getTask方法会被阻塞并挂起,不会占用cpu资源,整个getTask操作在自旋下(循环)完成)
-
队列已满,新建线程(非核心线程)执行任务
-
队列已满,总线程数又达到了maximumPoolSize,就会执行任务拒绝策略。
4. 常见线程池(四种)
(1) 可缓存线程池(CacheThreadPool)
根据源码可以看出:
这种线程池内部没有核心线程,线程的数量是有没限制的。
在创建任务时,若有空闲的线程时则复用空闲的线程,若没有则新建线程。
没有工作的线程(闲置状态)在超过了60S还不做事,就会销毁。
适用:执行很多短期异步的小程序或者负载较轻的服务器。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* 可缓存线程池
* 通过源码可以发现,底层是新建了一个ThreadPoolExecutor对象,构造方法中传入了几个参数
* int corePoolSize = 0 //核心线程数
int maximumPoolSize = Integer.MAX_VALUE //最大线程数
long keepAliveTime = 60L //线程存活时间
TimeUnit unit = TimeUnit.SECONDS //时间单位
BlockingQueue<Runnable> workQueue = new SynchronousQueue<Runnable>() //阻塞任务队列
* 同步队列没有任何内部容量,甚至连一个队列的容量都没有,只有前一个任务移除了,后一个任务才能进入
*
* 核心线程数
* 最大线程数
* 任务队列
* 拒绝策略
*
* 得出可缓存线程池的特点:
* 没有核心线程数量,最大线程数为整数的最大值(一直并发,没有限制最大并发量)
* 空闲线程存活时间为60S,任务队列为同步队列,队列中没有任何容量
*
* java中提供的写好的线程池底层都是类似的,都是通过Executors调用静态的方法返回对应的线程池对象
* 只是创建这个线程池对象的时候参数不同罢了,参数不同,线程池的特征也不一样
*/
public class CachedThreadPoolDemo {
public static void main(String[] args) {
//通过Executors获取一个线程池对象
ExecutorService executorService = Executors.newCachedThreadPool();
//调用execute提交任务
executorService.execute(()->{
for (int i = 0; i < 10; i++) {
try {
Thread.sleep(i);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"=="+i);
}
});
//调用execute提交任务
executorService.execute(()->{
for (int i = 0; i < 10; i++) {
try {
Thread.sleep(i);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"--"+i);
}
});
//executorService.shutdown();
}
}
(2) 定长线程池(FixedThreadPool)
根据源码可以看出:
该线程池的最大线程数等于核心线程数,所以在默认情况下,该线程池的线程不会因为闲置状态超时而被销毁。
如果当前线程数小于核心线程数,并且也有闲置线程的时候提交了任务,这时也不会去复用之前的闲置线程,会创建新的线程去执行任务。如果当前执行任务数大于了核心线程数,大于的部分就会进入队列等待。等着有闲置的线程来执行这个任务。
适用:执行长期的任务,性能好很多。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* 定长线程池
* 核心线程数 = 最大线程数 = 创建对象的时候构造方法的参数
* 超时时间设置为0,表示线程永远不会因为超时而被销毁
*
* 有阻塞队列 - 链表
*/
public class FixedThreadPoolDemo {
public static void main(String[] args) {
//创建线程池对象 -- 定长线程池
ExecutorService executorService = Executors.newFixedThreadPool(2);
//添加一个任务
executorService.execute(()->{
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+".......");//核心线程还没有,会新建一个核心线程执行任务
});
//添加一个任务
executorService.execute(()->{
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"+++++++");//核心线程只有一个,而且在执行任务,新建一个核心线程执行该任务
});
//添加一个任务
executorService.execute(()->{
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"--------");//两个核心线程都被占用,则任务添加到任务队列中,等到核心线程有空闲,则再执行
});
}
}
(3) 单线程线程池(SingleThreadPool)
根据源码可以看出:
有且仅有一个工作线程执行任务
所有任务按照指定顺序执行,即遵循队列的入队出队规则。
适用:一个任务一个任务执行的场景。
(4) 定时线程池(ScheduleThreadPool)
根据源码可以看出:
DEFAULT_KEEPALIVE_MILLIS就是默认10L,这里就是10秒。这个线程池有点像是CachedThreadPool和FixedThreadPool 结合了一下。
不仅设置了核心线程数,最大线程数也是Integer.MAX_VALUE。
这个线程池是上述4个中唯一一个有延迟执行和周期执行任务的线程池。
适用:周期性执行任务的场景(定期的同步数据)
(5) 常用线程池的原理
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* 不管你玩什么样的线程池,最终的原理都是调用ThreadPoolExecutor的构造方法,传入不同的参数值
int corePoolSize: 核心线程数
int maximumPoolSize: 最大线程数
long keepAliveTime: 线程空闲时间
TimeUnit unit: 时间单位
BlockingQueue<Runnable> workQueue: 任务队列
ThreadFactory threadFactory: 线程工厂 - 创建线程
RejectedExecutionHandler handler: 拒绝策略 - 默认拒绝策略
(抛出RejectedExecutionException)
*/
public class ThreadPoolExecutorDemo {
public static void main(String[] args) {
//自己根据参数创建一个单线程线程池
ExecutorService executorService =
new ThreadPoolExecutor(1,1,0L, TimeUnit.MILLISECONDS,new LinkedBlockingDeque<>());
}
}
(6) ThreadPoolExecutor类构造器语法形式
ThreadPoolExecutor(corePoolSize,maxPoolSize,keepAliveTime,timeUnit,workQueue,threadFactory,handle);
方法参数:
-
corePoolSize:核心线程数(最小存活的工作线程数量)
-
maxPoolSize:最大线程数
-
keepAliveTime:线程存活时间(在corePoreSize<maxPoolSize情况下有用,线程的空闲时间超过了keepAliveTime就会销毁)
-
timeUnit:存活时间的时间单位
-
workQueue:阻塞队列,用来保存等待被执行的任务
①synchronousQueue:这个队列比较特殊,它不会保存提交的任务,而是将直接新建一个线程来执行新来的任务;
②LinkedBlockingQueue:基于链表的先进先出队列,如果创建时没有指定此队列大小,则默认为Integer.MAX_VALUE;
③ArrayBlockingQueue:基于数组的先进先出队列,此队列创建时必须指定大小
-
threadFactory:线程工厂,主要用来创建线程;
-
handler:表示当拒绝处理任务时的策略
①丢弃任务并抛出RejectedExecutionException异常;
②丢弃任务,但是不抛出异常;
③丢弃队列最前面的任务,然后重新尝试执行任务;
④由调用线程处理该任务
(7) 在ThreadPoolExecutor类中几个重要的方法
-
execute()方法
实际上是Executor中声明的方法,在ThreadPoolExecutor进行了具体的实现,这个方法是ThreadPoolExecutor的核心方法,通过这个方法可以向线程池提交一个任务,交由线程池去执行。
-
submit()方法
是在ExecutorService中声明的方法,在AbstractExecutorService就已经有了具体的实现,在ThreadPoolExecutor中并没有对其进行重写,这个方法也是用来向线程池提交任务的,实际上它还是调用的execute()方法,只不过它利用了Future来获取任务执行结果。
-
shutdown()
不会立即终止线程池,而是要等所有任务缓存队列中的任务都执行完后才终止,但再也不会接受新的任务。
-
shutdownNow()
立即终止线程池,并尝试打断正在执行的任务,并且清空任务缓存队列,返回尚未执行的任务。
-
isTerminated()方法
调用ExecutorService.shutdown方法的时候,线程池不再接收任何新任务,但此时线程池并不会立刻退出,直到添加到线程池中的任务都已经处理完成,才会退出。在调用shutdown方法后我们可以在一个死循环里面用isTerminated方法判断是否线程池中的所有线程已经执行完毕,如果子线程都结束了,我们就可以做关闭流等后续操作了。
(8) 总结
除了new ScheduledThreadPool 的内部实现特殊一点之外,其它线程池内部都是基于ThreadPoolExecutor类(Executor的子类)实现的。
5. 线程池的最大线程数
一般说来,线程池的大小经验值应该这样设置:(其中N为CPU的个数)
-
CPU(计算)密集型:所有的操作都阻塞在了CPU中,计算多
-
IO密集型:所有的操作都阻塞在了IO上,IO多
如果是CPU密集型应用,则线程池大小设置为N+1
如果是IO密集型应用,则线程池大小设置为2N+1
如果一台服务器上只部署这一个应用并且只有这一个线程池,那么这种估算或许合理,具体还需自行测试验证。
但是,IO优化中,这样的估算公式可能更适合:
最佳线程数目 = ((线程等待时间+线程CPU时间)/线程CPU时间 )* CPU数目
因为很显然,线程等待时间所占比例越高,需要越多线程。线程CPU时间所占比例越高,需要越少线程。
创建线程的个数是还要考虑 内存资源是否足够装下相当的线程
举个栗子:
比如平均每个线程CPU运行时间为0.5s,而线程等待时间(非CPU运行时间,比如IO)为1.5s,CPU核心数为8,那么根据上面这个公式估算得到:((0.5+1.5)/0.5)*8=32。
二、 JVM优化
1. 概述
(1) 什么是JVM
Java的广告语是,”编写一次,到处运行”,而它凭借的就是JVM(Java Virtual Machine) 虚拟机.
对于不同的平台,Windows,Linux,Mac OS等,有具体不同的JVM版本.这些JVM屏蔽了平台的不同,提供了统一的运行环境,让Java代码无需考虑平台的差异,运行在相同的环境中.-Java跨平台的原理
JDK包含了Jre
Jre:Java的运行环境,在jvm虚拟机中运行,没有javac编译
Jdk:适用于开发环境,有javac编译
(2) 为什么要优化JVM
我们的Java代码都是运行在JVM中的,而部署的硬件及应用场景有所不同时,仍然采用默认的配置不见得能起到最好的效果,甚至可能会导致运行效率更差,又或者面临高并发情况下,想让程序平稳顺畅的运行,所以我们需要针对实际的需要来进行优化.
如果没有优化,那么jvm会使用默认配置
针对于不同的硬件和场景,我们应该适当调整jvm的参数
优化的时候要进行不断的测试,才能确定最优的方案,
那测试的时候如何查看jvm的各种状态呢?借助分析工具。
(3) JVM的分析工具安装
我们只知道有JVM的存在,但它的运行对于我们来说感觉像是摸不着看不见的,所以我们需要借助工具来监控它的一个实时状态,就像Windows的性能监视器一样,JDK也有自己的可视化工具.
我们以管理员身份运行DOS
输入jvisualvm,将Java VisualVM启动
安装VisualGC插件
在Tools–>Plugins 中的Settings标签页中点击Add按钮,把新地址填进去,然后把
新增的设为选中状态,原来的勾选去掉。然后就可以在Available Plugins中找到要找
的插件了。ps:若是没生效,可能需要重启一下jvisualVM。
https://visualvm.github.io/uc/8u131/updates.xml.gz
2. JVM的组成
从图上可以看到,大致分为以下组件:
-
类加载器子系统
-
运行时数据区
方法区堆 虚拟机栈 本地方法栈 程序计数器
-
执行引擎
-
本地方法库
而本地库接口也就是用于调用本地方法的接口,主要关注的是上述的4个组件
(1) 类加载子系统
① 类加载步骤
加载:找到字节码文件,读取到内存中
验证:验证此字节码文件是不是真的是一个字节码文件
准备:为类中static修饰的变量分配内存空间并设置其初始值为0或null
解析:将java代码中的符号引用替换为直接引用
初始化:如刚才准备阶段所说的,这个阶段就是对变量的赋值的阶段
② 类加载的机制
不同类加载器加载的同一个字节码文件,得到的类都不相等
类加载器一般有4种,其中前3种是必然存在的
-
启动类加载器:加载<JAVA_HOME>\lib下的
-
扩展类加载器:加载<JAVA_HOME>\lib\ext下的
-
应用程序类加载器:加载Classpath下的
-
自定义类加载器
双亲委派机制
Eg:
如果自己写个String类,包名和java自定义的String类包名一致.
那么去调用String的时候,默认先调用Java自定义的类
向上委派,向下加载
/**
* 类加载器要保证java类库中的类优先加载,
* 否则如果项目中有和java类库中的类相同的包和类名,
* 如果覆盖了类库中的类,会导致java所有使用这个类的地方出现错误,
* 最终项目无法成功运行
*
* jvm是如何保证类的加载顺序????
* 双亲委派机制
*
* bootstrap classloader 启动类加载器 -> <JAVA_HOME>\lib java类库
*
* extension classloader 扩展类加载器 -> <JAVA_HOME>\lib\ext java扩展类库
*
* application classloader 应用程序类加载器 -> claspath 项目中的类
*
* user classloader 自定义类加载器
*
* 向上委派,向下加载
*
*/
public class ClassLoaderDemo {
public static void main(String[] args) {
String string = new String();//创建的是java类库中的String,而不是我的String
//string.test();
}
}
(2) 运行时数据区
运行时数据区分为虚拟机栈,本地方法栈,堆区,方法区和程序计数器.
(3) 运行时数据区-程序计数器
程序计数器是线程私有的,虽然名字叫计数器,但主要用途还是用来确定指令的执行顺序,比如循环,分支,跳转,异常捕获等.而JVM对于多线程的实现是通过轮流切换线程实现的,所以为了保证每个线程都能按正确顺序执行,将程序计数器作为线程私有.程序计数器是唯一一个JVM没有规定任何OOM的区块.
oom out of memory
程序计数器是一块非常小的内存空间,可以看做是当前线程执行字节码的行号指示器,每个线程都有一个独立的程序计数器,因此程序计数器是线程私有的一块空间,此外,程序计数器是Java虚拟机规定的唯一不会发生内存溢出的区域。
(4) 运行时数据区-虚拟机栈
StackOverflowError: 栈的深度查过了jvm允许的最大深度
OOM(OutOfMemoryError): 扩展栈的大小的时候,申请不到足够的内存空间
Java虚拟机栈也是线程私有的,每个方法执行都会创建一个栈帧,局部变量就存放在栈帧中,还有一些其他的动态链接之类的.通常有两个错误会跟这个有关系,一个是StackOverFlowError,一个是OOM(OutOfMemoryError).前者是因为线程请求栈深度超出虚拟机所允许的范围,后者是动态扩展栈的大小的时候,申请不到足够的内存空间.而前者提到的栈深度,也就是刚才说到的每个方法会创建一个栈帧,栈帧从开始执行方法时压入Java虚拟机栈,执行完的时候弹出栈.当压入的栈帧太多了,就会报出这个StackOverflowError.
虚拟机会为每个线程分配一个虚拟机栈,每个虚拟机栈中都有若干个栈帧,每个栈帧中存储了局部变量表、操作数栈、动态链接、返回地址等。一个栈帧就对应Java代码中的一个方法,当线程执行到一个方法时,就代表这个方法对应的栈帧已经进入虚拟机栈并且处于栈顶的位置,每一个Java方法从被调用到执行结束,就对应了一个栈帧从入栈到出栈的过程。
(5) 运行时数据区-方法区(元空间)
在jdk1.8中,方法区已经不存在,原方法区中存储的类信息、编译后的代码数据等已经移动到了元空间(MetaSpace)中,元空间并没有处于堆内存上,而是直接占用的本地内存(NativeMemory).元空间的大小仅受本地内存限制,但可以通过-XX:MetaspaceSize和-XX:MaxMetaspaceSize来指定元空间的大小
去永久代的原因有:
(1)字符串存在永久代中,容易出现性能问题和内存溢出。
(2)类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
(3)永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
GC: garbage collection
1.8以后把静态变量 字符串常量池放到了堆中
类的信息 编译后的代码放到元空间 占用的是本地内存
(6) 运行时数据区-堆
存放对象的区域,也是GC的主要区域
堆还可以分为新生代(YoungGeneration)和老年代(OldGeneration),新生代
还可以分为Eden、From Survivor、To Survivor()
3. JVM的内存溢出
(1) 堆内存溢出
我们一直创建对象,并且这些对象一直有引用,不会被垃圾回收,最终会导致堆内存溢出
堆内存中主要存放对象、数组等,只要不断地创建这些对象,并且保证GC Roots到对象之间有可达路径来避免垃圾收集回收机制清除这些对象,当这些对象所占空间超过最大堆容量时,就会产生OutOfMemoryError的异常。
import java.util.ArrayList;
import java.util.List;
public class HeapOOMDemo {
static class OOMObject{}
public static void main(String[] args) {
//-Xms20m -Xmx20m
//Xms : 堆初始化内存大小
//Xmx : 堆最大内存大小
//Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
List<OOMObject> list = new ArrayList<>();
/**
* 死循环
*/
while (true){
list.add(new OOMObject());
}
}
}
运行后会报异常,在堆栈信息中可以看到 java.lang.OutOfMemoryError: Java heap space 的信息,说明在堆内存空间产生内存溢出的异常。
(2) 栈内存溢出
① StackOverflowError:当线程请求的栈的深度大于虚拟机所允许的最大深度,则抛出StackOverflowError
简单理解就是虚拟机栈中的栈帧数量过多(一个线程嵌套调用的方法数量过多)时,就会抛出StackOverflowError异常。最常见的场景就是方法无限递归调用
public class StackOverFlowErrorDemo {
public int num = 0;
public void doSomething(){
num++;
doSomething();
}
public static void main(String[] args) {
StackOverFlowErrorDemo demo = new StackOverFlowErrorDemo();
try{
demo.doSomething(); //Exception in thread "main" java.lang.StackOverflowError
}catch (Throwable e){
System.out.println("栈深度:"+demo.num);//栈深度:19484
throw e;
}
}
}
上述代码执行后抛出:Exception in thread “Thread-0” java.lang.StackOverflowError的异常。
② OutOfMemoryError:如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError。
我们可以这样理解,虚拟机中可以供栈占用的空间≈可用物理内存 - 最大堆内存 - 最大方法区内存,比如一台机器内存为4G,系统和其他应用占用2G,虚拟机可用的物理内存为2G,最大堆内存为1G,最大方法区内存为512M,那可供栈占有的内存大约就是512M,假如我们设置每个线程栈的大小为1M,那虚拟机中最多可以创建512个线程,超过512个线程再创建就没有空间可以给栈了,就报OutOfMemoryError异常了。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8C9fvObE-1587310456001)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200419230412600.png)]
/**
* 设置每个线程的栈大小:-Xss2m
* 运行时,不断创建新的线程(且每个线程持续执行),每个线程对一个一个栈,最终没有多余的空间来为新的线程分配,导致OutOfMemoryError
*/
public class StackOOM {
private static int threadNum = 0;
public void doSomething() {
try {
Thread.sleep(100000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
final StackOOM stackOOM = new StackOOM();
try {
while (true) {
threadNum++;
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
stackOOM.doSomething();
}
});
thread.start();
}
} catch (Throwable e) {
System.out.println("目前活动线程数量:" + threadNum);
throw e;
}
}
}
上述代码运行后会报异常,在堆栈信息中可以看到 java.lang.OutOfMemoryError: unable to create new native thread的信息,无法创建新的线程,说明是在扩展栈的时候产生的内存溢出异常。
(3) 总结
在线程较少的时候,某个线程请求深度过大,会报StackOverflow异常,解决这种问题可以适当加大栈的深度(增加栈空间大小),也就是把-Xss的值设置大一些,但一般情况下是代码问题的可能性较大;
在虚拟机产生线程时,无法为该线程申请栈空间了,会报OutOfMemoryError异常,解决这种问题可以适当减小栈的深度,也就是把-Xss的值设置小一些,每个线程占用的空间小了,总空间一定就能容纳更多的线程,但是操作系统对一个进程的线程数有限制,经验值在3000~5000左右。
在jdk1.5之前-Xss默认是256k,jdk1.5之后默认是1M,这个选项对系统硬性还是蛮大的,设置时要根据实际情况,谨慎操作。
(4) 扩展
方法区溢出
方法区主要用于存储虚拟机加载的类信息、常量、静态变量,以及编译器编译后的代码等数据,所以方法区溢出的原因就是没有足够的内存来存放这些数据。
由于在jdk1.6之前字符串常量池是存在于方法区中的,所以基于jdk1.6之前的虚拟机,可以通过不断产生不一致的字符串(同时要保证和GC Roots之间保证有可达路径)来模拟方法区的OutOfMemoryError异常;但方法区还存储加载的类信息,所以基于jdk1.7的虚拟机,可以通过动态不断创建大量的类来模拟方法区溢出。
本机直接内存溢出
本机直接内存(DirectMemory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但Java中用到NIO相关操作时(比如ByteBuffer的allocteDirect方法申请的是本机直接内存),也可能会出现内存溢出的异常。
IO与NIO区别?
4. 垃圾回收
垃圾回收,就是通过垃圾收集器把内存中没用的对象清理掉。
垃圾回收涉及到的内容有:
1、判断对象是否已死;
2、选择垃圾收集算法;
3、选择垃圾收集的时间;
4、选择适当的垃圾收集器清理垃圾(已死的对象)。
(1) 判断对象是否已死
判断对象是否已死就是找出哪些对象是已经死掉的,以后不会再用到的,就像地上有废纸、饮料瓶和百元大钞,扫地前要先判断出地上废纸和饮料瓶是垃圾,百元大钞不是垃圾。判断对象是否已死有引用计数算法和可达性分析算法。
① 引用计数算法
给每一个对象添加一个引用计数器,每当有一个地方引用它时,计数器值加1;每当有一个地方不再引用它时,计数器值减1,这样只要计数器的值不为0,就说明还有地方引用它,它就不是无用的对象。如下图,对象2有1个引用,它的引用计数器值为1,对象1有两个地方引用,它的引用计数器值为2 。
这种方法看起来非常简单,但目前许多主流的虚拟机都没有选用这种算法来管理内存,原因就是当某些对象之间互相引用时,无法判断出这些对象是否已死,如下图,对象1和对象2都没有被堆外的变量引用,而是被对方互相引用,这时他们虽然没有用处了,但是引用计数器的值仍然是1,无法判断他们是死对象,垃圾回收器也就无法回收。
② 可达性分析算法
了解可达性分析算法之前先了解一个概念——GC Roots,垃圾收集的起点,可以作为GC Roots的有虚拟机栈中本地变量表中引用的对象、方法区中静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI(Native方法)引用的对象。
栈中的变量和静态属性直接引用的对象都是GC Roots
当一个对象到GC Roots没有任何引用链相连(GC Roots到这个对象不可达)时,就说明此对象是不可用的,是死对象。如下图:object1、object2、object3、object4和GC Roots之间有可达路径,这些对象不会被回收,但object5、object6、object7到GC Roots之间没有可达路径,这些对象就被判了死刑。
上面被判了死刑的对象(object5、object6、object7)并不是必死无疑,还有挽救的余地。进行可达性分析后对象和GC Roots之间没有引用链相连时,对象将会被进行一次标记,接着会判断如果对象没有覆盖Object的finalize()方法或者finalize()方法已经被虚拟机调用过,那么它们就会被行刑(清除);如果对象覆盖了finalize()方法且还没有被调用,则会执行finalize()方法中的内容,所以在finalize()方法中如果重新与GC Roots引用链上的对象关联就可以拯救自己,但是一般不建议这么做.
③ 方法区回收
上面说的都是对堆内存中对象的判断,方法区中主要回收的是废弃的常量和无用的类。
判断常量是否废弃可以判断是否有地方引用这个常量,如果没有引用则为废弃的常量。
判断类是否废弃需要同时满足如下条件:
-
该类所有的实例已经被回收(堆中不存在任何该类的实例)
-
加载该类的ClassLoader已经被回收
-
该类对应的java.lang.Class对象在任何地方没有被引用(无法通过反射访问该类的方法)
(2) 垃圾收集算法
① 标记 - 清除算法
分为标记和清除两个阶段,首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象,如下图。
先标记垃圾对象,再统一清理
缺点:标记和清除两个过程效率都不高、标记清除之后会产生大量不连续的内存碎片
② 复制算法
把内存分为大小相等的两块,每次存储只用其中一块,当这一块用完了,就把存活的对象全部复制到另一块上,同时把使用过的这块内存空间全部清理掉,往复循环,如下图。
内存分为大小相等的两部分
每次只使用一部分
将这部分的可用对象复制到另一半中,再将这部分的内存清理掉
优点:快
缺点:实际使用的内存空间减小到原来的一半
③ 标记 - 整理算法
先对可用的对象进行标记,然后所有被标记的对象向一段移动,最后清除可用对象边界以外的内存,如下图。
标记:标记可用对象,将可用对象移动到一端,清理可用对象边界以后的内存空
间。
优点:连续的内存空间
缺点:效率低,但是没有内存碎片,对内存的利用率高
④ 分代收集算法
新生代:使用复制算法
因为新生代的对象生命周期大多都很短暂,使用复制算法的话,因为生命周期短暂的原因,使用到内存的时间非常短暂,提高了效率,还不会产生内存碎片;
老年代:使用标记整理算法
老年代的对象生命周期比较顽强,比较不容易被清除掉,对象会越来越多,需要整理,而且清除的时候,不会产生内存碎片,因为老年代对象比较多,如果产生大量的内存碎片或者使用复制算法的话,都会占用大量的内存,性价比非常低.
⑤ Minor GC和Full GC
每次垃圾回收的时候,都会将整个JVM暂停,回收完成后再继续
(3) 垃圾收集时间
安全点:
线程中的指令指定到某一个刻的时候允许执行垃圾回收,挂起用户线程,不会产生影响.
从线程角度看,安全点可以理解为是在代码执行过程中的一些特殊位置,当线程执行到安全点的时候,说明虚拟机当前的状态是安全的,如果有需要,可以在这里暂停用户线程。当垃圾收集时,如果需要暂停当前的用户线程,但用户线程当时没在安全点上,则应该等待这些线程执行到安全点再暂停。举个例子,妈妈在扫地,儿子在吃西瓜(瓜皮会扔到地上),妈妈扫到儿子跟前时,儿子说:“妈妈等一下,让我吃完这块再扫。”儿子吃完这块西瓜把瓜皮扔到地上后就是一个安全点,妈妈可以继续扫地(垃圾收集器可以继续收集垃圾)。理论上,解释器的每条字节码的边界上都可以放一个安全点,实际上,安全点基本上以“是否具有让程序长时间执行的特征”为标准进行选定。
安全区:一段连续的安全点
安全点是相对于运行中的线程来说的,对于如sleep或blocked等状态的线程,收集器不会等待这些线程被分配CPU时间,这时候只要线程处于安全区中,就可以算是安全的。安全区就是在一段代码片段中,引用关系不会发生变化,可以看作是被扩展、拉长了的安全点。还以上面的例子说明,妈妈在扫地,儿子在吃西瓜(瓜皮会扔到地上),妈妈扫到儿子跟前时,儿子说:“妈妈你继续扫地吧,我还得吃10分钟呢!”儿子吃瓜的这段时间就是安全区,妈妈可以继续扫地(垃圾收集器可以继续收集垃圾)。
(4) 垃圾收集器
现在常见的垃圾收集器有如下几种
新生代收集器:Serial、ParNew、Parallel Scavenge
老年代收集器:Serial Old、CMS、Parallel Old
堆内存垃圾收集器:G1
每种垃圾收集器之间有连线,表示他们可以搭配使用。
针对于老年代和新生代都有不同的垃圾收集器
调整内存大小,选择合适的垃圾收集器,设置垃圾收集器的参数,原则?
① Serial 收集器
Serial是一款用于新生代的单线程收集器,采用复制算法进行垃圾收集。Serial进行垃圾收集时,不仅只用一条线程执行垃圾收集工作,它在收集的同时,所有的用户线程必须暂停(Stop The World)。就比如妈妈在家打扫卫生的时候,肯定不会边打扫边让儿子往地上乱扔纸屑,否则一边制造垃圾,一遍清理垃圾,这活啥时候也干不完。
如下是Serial收集器和Serial Old收集器结合进行垃圾收集的示意图,当用户线程都执行到安全点时,所有线程暂停执行,Serial收集器以单线程,采用复制算法进行垃圾收集工作,收集完之后,用户线程继续开始执行。
适用场景:Client模式(桌面应用);单核服务器。可以用-XX:+UserSerialGC来选择Serial作为新生代收集器。
② ParNew 收集器
ParNew就是一个Serial的多线程版本,其它与Serial并无区别。ParNew在单核CPU环境并不会比Serial收集器达到更好的效果,它默认开启的收集线程数和CPU数量一致,可以通过-XX:ParallelGCThreads来设置垃圾收集的线程数。
如下是ParNew收集器和Serial Old收集器结合进行垃圾收集的示意图,当用户线程都执行到安全点时,所有线程暂停执行,ParNew收集器以多线程,采用复制算法进行垃圾收集工作,收集完之后,用户线程继续开始执行。
适用场景:多核服务器;与CMS收集器搭配使用。当使用-XX:+UserConcMarkSweepGC来选择CMS作为老年代收集器时,新生代收集器默认就是ParNew,也可以用-XX:+UseParNewGC来指定使用ParNew作为新生代收集器。
③ Parallel Scavenge 收集器
Parallel Scavenge也是一款用于新生代的多线程收集器,与ParNew的不同之处是,ParNew的目标是尽可能缩短垃圾收集时用户线程的停顿时间,Parallel Scavenge的目标是达到一个可控制的吞吐量。吞吐量就是CPU执行用户线程的的时间与CPU执行总时间的比值【吞吐量=运行用户代代码时间/(运行用户代码时间+垃圾收集时间)】,比如虚拟机一共运行了100分钟,其中垃圾收集花费了1分钟,那吞吐量就是99% 。比如下面两个场景,垃圾收集器每100秒收集一次,每次停顿10秒,和垃圾收集器每50秒收集一次,每次停顿时间7秒,虽然后者每次停顿时间变短了,但是总体吞吐量变低了,CPU总体利用率变低了。 tps qps rt 并发数
可以通过-XX:MaxGCPauseMillis来设置收集器尽可能在多长时间内完成内存回收,可以通过-XX:GCTimeRatio来精确控制吞吐量。
如下是Parallel收集器和Parallel Old收集器结合进行垃圾收集的示意图,在新生代,当用户线程都执行到安全点时,所有线程暂停执行,ParNew收集器以多线程,采用复制算法进行垃圾收集工作,收集完之后,用户线程继续开始执行;在老年代,当用户线程都执行到安全点时,所有线程暂停执行,Parallel Old收集器以多线程,采用标记整理算法进行垃圾收集工作。
适用场景:注重吞吐量,高效利用CPU,需要高效运算且不需要太多交互。可以使用-XX:+UseParallelGC来选择Parallel Scavenge作为新生代收集器,jdk7、jdk8默认使用Parallel Scavenge作为新生代收集器。
④ Serial Old收集器
Serial Old收集器是Serial的老年代版本,同样是一个单线程收集器,采用标记-整理算法。
如下图是Serial收集器和Serial Old收集器结合进行垃圾收集的示意图:
适用场景:Client模式(桌面应用);单核服务器;与Parallel Scavenge收集器搭配;作为CMS收集器的后备预案。
⑤ CMS(Concurrent Mark Sweep) 收集器
CMS收集器是一种以最短回收停顿时间为目标的收集器,以“最短用户线程停顿时间”著称。整个垃圾收集过程分为4个步骤
① 初始标记:标记一下GC Roots能直接关联到的对象,速度较快
② 并发标记:进行GC Roots Tracing,标记出全部的垃圾对象,耗时较长
③ 重新标记:修正并发标记阶段引用户程序继续运行而导致变化的对象的标记记录,耗时较短
④ 并发清除:用标记-清除算法清除垃圾对象,耗时较长
整个过程耗时最长的并发标记和并发清除都是和用户线程一起工作,所以从总体上来说,CMS收集器垃圾收集可以看做是和用户线程并发执行的。
CMS收集器也存在一些缺点:
-
对CPU资源敏感:默认分配的垃圾收集线程数为(CPU数+3)/4,随着CPU数量下降,占用CPU资源越多,吞吐量越小
-
无法处理浮动垃圾:在并发清理阶段,由于用户线程还在运行,还会不断产生新的垃圾,CMS收集器无法在当次收集中清除这部分垃圾。同时由于在垃圾收集阶段用户线程也在并发执行,CMS收集器不能像其他收集器那样等老年代被填满时再进行收集,需要预留一部分空间提供用户线程运行使用。当CMS运行时,预留的内存空间无法满足用户线程的需要,就会出现“Concurrent Mode Failure”的错误,这时将会启动后备预案,临时用Serial Old来重新进行老年代的垃圾收集。
-
因为CMS是基于标记-清除算法,所以垃圾回收后会产生空间碎片,可以通过-XX:UserCMSCompactAtFullCollection开启碎片整理(默认开启),在CMS进行Full GC之前,会进行内存碎片的整理。还可以用-XX:CMSFullGCsBeforeCompaction设置执行多少次不压缩(不进行碎片整理)的Full GC之后,跟着来一次带压缩(碎片整理)的Full GC。
适用场景:重视服务器响应速度,要求系统停顿时间最短。可以使用-XX:+UserConMarkSweepGC来选择CMS作为老年代收集器。
⑥ Parallel Old 收集器
Parallel Old收集器是Parallel Scavenge的老年代版本,是一个多线程收集器,采用标记-整理算法。可以与Parallel Scavenge收集器搭配,可以充分利用多核CPU的计算能力。
适用场景:与Parallel Scavenge收集器搭配使用;注重吞吐量。jdk7、jdk8默认使用该收集器作为老年代收集器,使用 -XX:+UseParallelOldGC来指定使用Paralle Old收集器。
⑦ G1 收集器
G1 收集器是jdk1.7才正式引用的商用收集器,现在已经成为jdk9默认的收集器。前面几款收集器收集的范围都是新生代或者老年代,G1进行垃圾收集的范围是整个堆内存,它采用“化整为零”的思路,把整个堆内存划分为多个大小相等的独立区域(Region),在G1收集器中还保留着新生代和老年代的概念,它们分别都是一部分Region,如下图:
每一个方块就是一个区域,每个区域可能是Eden、Survivor、老年代,每种区域的数量也不一定。JVM启动时会自动设置每个区域的大小(1M~32M,必须是2的次幂),最多可以设置2048个区域(即支持的最大堆内存为32M*2048=64G),假如设置-Xmx8g -Xms8g,则每个区域大小为8g/2048=4M。
为了在GC Roots Tracing的时候避免扫描全堆,在每个Region中,都有一个Remembered Set来实时记录该区域内的引用类型数据与其他区域数据的引用关系(在前面的几款分代收集中,新生代、老年代中也有一个Remembered Set来实时记录与其他区域的引用关系),在标记时直接参考这些引用关系就可以知道这些对象是否应该被清除,而不用扫描全堆的数据。
G1收集器可以“建立可预测的停顿时间模型”,它维护了一个列表用于记录每个Region回收的价值大小(回收后获得的空间大小以及回收所需时间的经验值),这样可以保证G1收集器在有限的时间内可以获得最大的回收效率。
如下图所示,G1收集器收集器收集过程有初始标记、并发标记、最终标记、筛选回收,和CMS收集器前几步的收集过程很相似:
- 初始标记:标记出GC Roots直接关联的对象,这个阶段速度较快,需要停止用户线程,单线程执行
-
并发标记:从GC Root开始对堆中的对象进行可达新分析,找出存活对象,这个阶段耗时较长,但可以和用户线程并发执行
-
最终标记:修正在并发标记阶段引用户程序执行而产生变动的标记记录
-
筛选回收:筛选回收阶段会对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来指定回收计划(用最少的时间来回收包含垃圾最多的区域,这就是Garbage First的由来——第一时间清理垃圾最多的区块),这里为了提高回收效率,并没有采用和用户线程并发执行的方式,而是停顿用户线程。
适用场景:要求尽可能可控GC停顿时间;内存占用较大的应用。可以用-XX:+UseG1GC使用G1收集器,jdk9默认使用G1收集器。
5. JVM的优化
(1) 目标
使用较小的内存占用来获得较高的吞吐量或者较低的延迟。不能两者兼得
程序在上线前的测试或运行中有时会出现一些大大小小的JVM问题,比如cpu load过高、请求延迟、tps降低等,甚至出现内存泄漏(每次垃圾收集使用的时间越来越长,垃圾收集频率越来越高,每次垃圾收集清理掉的垃圾数据越来越少)、内存溢出导致系统崩溃,因此需要对JVM进行调优,使得程序在正常运行的前提下,获得更高的用户体验和运行效率。
这里有几个比较重要的指标:
-
**内存占用:**程序正常运行需要的内存大小。
-
**延迟:**由于垃圾收集而引起的程序停顿时间。
-
**吞吐量:**用户程序运行时间占用户程序和垃圾收集占用总时间的比值。
当然,和CAP原则一样,同时满足一个程序内存占用小、延迟低、高吞吐量是不可能的,程序的目标不同,调优时所考虑的方向也不同,在调优之前,必须要结合实际场景,有明确的的优化目标,找到性能瓶颈,对瓶颈有针对性的优化,最后进行测试,通过各种监控工具确认调优后的结果是否符合目标。
调优可以依赖、参考的数据有系统运行日志、堆栈错误信息、gc日志、线程快照、堆转储快照等。
① 系统运行日志:系统运行日志就是在程序代码中打印出的日志,描述了代码级别的系统运行轨迹(执行的方法、入参、返回值等),一般系统出现问题,系统运行日志是首先要查看的日志。
② 堆栈错误信息:当系统出现异常后,可以根据堆栈信息初步定位问题所在,比如根据“java.lang.OutOfMemoryError: Java heap space”可以判断是堆内存溢出;根据“java.lang.StackOverflowError”可以判断是栈溢出;根据“java.lang.OutOfMemoryError: PermGen space”可以判断是方法区溢出等。
③ GC日志:程序启动时用 -XX:+PrintGCDetails 和 -Xloggc:/data/jvm/gc.log 可以在程序运行时把gc的详细过程记录下来,或者直接配置“-verbose:gc”参数把gc日志打印到控制台,通过记录的gc日志可以分析每块内存区域gc的频率、时间等,从而发现问题,进行有针对性的优化。
④ 线程快照:顾名思义,根据线程快照可以看到线程在某一时刻的状态,当系统中可能存在请求超时、死循环、死锁等情况是,可以根据线程快照来进一步确定问题。通过执行虚拟机自带的“jstack pid”命令,可以dump出当前进程中线程的快照信息,更详细的使用和分析网上有很多例,这篇文章写到这里已经很长了就不过多叙述了,贴一篇博客供参考:http://www.cnblogs.com/kongzhongqijing/articles/3630264.html
⑤ 堆转储快照:程序启动时可以使用 “-XX:+HeapDumpOnOutOfMemory” 和 “-XX:HeapDumpPath=/data/jvm/dumpfile.hprof”,当程序发生内存溢出时,把当时的内存快照以文件形式进行转储(也可以直接用jmap命令转储程序运行时任意时刻的内存快照),事后对当时的内存使用情况进行分析。
JVM的优化我们可以从JIT优化,内存分区设置优化以及GC选择优化三个方面入手。
(2) 参数
参数 说明 实例
-Xms 初始堆大小,默认物理内存的1/64 -Xms512M
-Xmx 最大堆大小,默认物理内存的1/4 -Xms2G
-Xmn 新生代内存大小,官方推荐为整个堆的3/8 -Xmn512M
-Xss 线程堆栈大小,jdk1.5及之后默认1M,之前默认256k -Xss512k
-XX:NewRatio=n 设置新生代和年老代的比值。如:为3,表示年轻代与年老代比值为1:3,年轻代占整个年轻代年老代和的1/4 -XX:NewRatio=3
-XX:SurvivorRatio=n 年轻代中Eden区与两个Survivor区的比值。注意Survivor区有两个。如:8,表示Eden:Survivor=8:1:1,一个Survivor区占整个年轻代的1/8 -XX:SurvivorRatio=8
-XX:PermSize=n 永久代初始值,默认为物理内存的1/64 -XX:PermSize=128M
-XX:MaxPermSize=n 永久代最大值,默认为物理内存的1/4 -XX:MaxPermSize=256M
-verbose:class 在控制台打印类加载信息
-verbose:gc 在控制台打印垃圾回收日志
-XX:+PrintGC 打印GC日志,内容简单
-XX:+PrintGCDetails 打印GC日志,内容详细
-XX:+PrintGCDateStamps 在GC日志中添加时间戳
-Xloggc:filename 指定gc日志路径 -Xloggc:/data/jvm/gc.log
-XX:+UseSerialGC 年轻代设置串行收集器Serial
-XX:+UseParallelGC 年轻代设置并行收集器Parallel Scavenge
-XX:ParallelGCThreads=n设置Parallel Scavenge收集时使用的CPU数。并行收集线程数。 -XX:ParallelGCThreads=4
-XX:MaxGCPauseMillis=n 设置Parallel Scavenge回收的最大时间(毫秒) -XX:MaxGCPauseMillis=100
-XX:GCTimeRatio=n设置Parallel Scavenge垃圾回收时间占程序运行时间的百分比。公式为1/(1+n) -XX:GCTimeRatio=19
-XX:+UseParallelOldGC 设置老年代为并行收集器ParallelOld收集器
-XX:+UseConcMarkSweepGC 设置老年代并发收集器CMS
-XX:+CMSIncrementalMode 设置CMS收集器为增量模式,适用于单CPU情况。
设置每个区域的内存大小 设计每个区域不同的垃圾回收器
(3) 设置参数的方式
1、集成开发环境下启动并使用JVM,如eclipse需要修改根目录文件eclipse.ini;
2、Windows服务器下安装版Tomcat,可使用Tomcat7w.exe工具(tomcat目录下)和直接修改注册表两种方式修改Jvm参数;
3、Windows服务器解压版Tomcat注册Windows服务,方法同上;
4、解压版本的Tomcat, 通过startup.bat启动tomcat加载配置的,在tomcat 的bin 下catalina.bat 文件内添加;
5、Linux服务器Tomcat设置JVM,修改TOMCAT_HOME/bin/catalina.sh;
6、windows环境下配置JAVA_OPTS
7、SpringBoot项目 : java -jar xxx.jar jvm参数 --》 shell脚本中运行
8、idea 中