JVM面试题总结

1.JVM垃圾回收的时候如何确定垃圾?是否知道什么是GC Roots?

什么垃圾:内存中不在被使用到的空间就是垃圾

判断对象时垃圾的方法:

  • 引用计数法:一个对象被引用其引用值就+1,取消引用就-1,很难解决循环引用的问题
  • 可达性分析法:根搜索算法的基本思路就是通过一系列名为”GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。这个算法的基本思想是通过一系列称为“GC Roots”的对象作为起始点,从这些节点向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链(即GC Roots到对象不可达)时,则证明此对象是不可用的。

哪些对象可以作为GC Roots

  • 虚拟机栈(栈帧中的局部变量区,也叫做局部变量表)中引用的对象。
  • 方法区中的类静态属性引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中JNI(Native方法)引用的对象。

2.如何盘点查看JVM系统默认值

JVM参数类型:

  • 标配参数:-version,-help,java -showversion
  • X参数:-Xint(解释执行),-Xcomp(第一次执行就编译成本地代码),-Xmixed(混合模式)

  • XX参数:Boolean类型,KV设值类型,jinfo举例
  • Boolean类型:-XX:+或者-某个属性,+表示开启,-表示关闭

  • KV设值类型:-XX:属性key=属性值value(-XX:MetaspaceSize=1024m设置原空间的大小)

  • jinfo举例:-flags(查询全部参数)

-Xmx与-Xms属于哪一类呢?

-Xmx--->-XX:MaxHeapSize,-Xms--->-XX:InitialHeapSize

查看JVM默认值:

  • -XX:+PrintFlagsInitial:java -XX:+PrintFlagsInitial (安装时的默认值) =表示初始值
  • -XX:+PrintFlagsFinal:java -XX:+PrintFlagsFinal (主要查看修改更新):=表示后来修改(可以人工自改或JVM根据电脑硬件自动修改)
  • -XX:+PrintCommandLineFlags:java -XX:+PrintCommandLineFlags -version(打印命令行参数,可以查看当前JVM使用的垃圾回收器)

3.常用JVM基本配置参数

  • -Xmx:最大分配内存,默认为物理内存的1/4
  • -Xms:初始分配内存,默认为物理内存的1/64
  • -Xss:等价于-XX:ThreadStackSize,单个线程栈空间大小,默认一般为512k-1024k,通过jinfo查看为0时,表示使用默认值
  • -Xmn:设置年轻代大小
  • -XX:MetaspeaceSize:设置元空间大小(默认21M左右,可以配置大一些),元空间的本质可永久代类似,都是对JVM规范中方法区的实现,不过元空间与永久代的最大区别在于:元空间不在虚拟机中,而是使用本地内存,因此,默认情况下,元空间大小仅受本地内存大小限制
  • 典型设置案例:-Xms128m -Xmx4096m -Xss1024k -XX:MetaspaceSize=512m -XX:+PrintCommandLineFlags -XX:+PrintGCDetails -XX:+UseSerialGC
  • -XX:+PrintGCDetails:打印垃圾回收细节,打印GC: 打印Full GC:

  • -XX:SurvivorRatio:调整Eden中survivor区比例,默认-XX:SurvivorRatio=8(8:1:1),调整为-XX:SurvivorRatio=4(4:1:1),一般使用默认值
  • -XX:NewRatio:调整新生代与老年代的比例,默认为2(新生代1,老年代2,年轻代占整个堆的1/3),调整为-XX:NewRatio=4表示(新生代1,老年代4,年轻代占堆的1/5),一般使用默认值
  • -XX:MaxTenuringThreshold:设置垃圾的最大年龄(经历多少次垃圾回收进入老年代),默认15(15次垃圾回收后依旧存活的对象进入老年代),JDK1.8设置必须0<-XX:MaxTenuringThreshold<15

4.强引用,软引用,弱引用,虚引用

1.整体架构:存在于java.lang.ref包下

2.强引用:  当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题,在JAVA中最常用就是强引用,只有使用obj=null,将其弱化,JVM才会回收。

3.软引用:如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用,软引用可用来实现内存敏感的高速缓存。

/**
	 * 内存够用保留
	 */
	public static void softRefMemoryEnough(){
		Object object = new Object();
		SoftReference<Object> softReference = new SoftReference<Object>(object);
		System.out.println(object);
		System.out.println(softReference.get());
		object=null;
		System.gc();
		System.out.println(object);
		System.out.println(softReference.get());//不会回收
	}
	
	/**
	 * 内存不够用回收
	 */
	public static void softRefMemoryNotEnough(){
		Object object = new Object();
		SoftReference<Object> softReference = new SoftReference<Object>(object);
		System.out.println(object);
		System.out.println(softReference.get());
		object=null;
		try {
			byte[] bytearray=new byte[30*1024*1024];//-Xms20m -Xmx20m
		} catch (Exception e) {
			e.printStackTrace();
		}finally{
			System.out.println(object);
			System.out.println(softReference.get());//会回收
		}
	}

4.弱引用:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。 

public static void weakRefMemoryEnough(){
		Object object = new Object();
		WeakReference<Object> weakReference = new WeakReference<Object>(object);
		System.out.println(object);
		System.out.println(weakReference.get());
		object=null;
		System.gc();
		System.out.println(object);
		System.out.println(weakReference.get());//会回收
	}

5.WeakHashMap:不过WeakHashMap的键是“弱键”。在 WeakHashMap 中,当某个键不再正常使用时,会被从WeakHashMap中被自动移除。更精确地说,对于一个给定的键,其映射的存在并不阻止垃圾回收器对该键的丢弃,这就使该键成为可终止的,被终止,然后被回收。某个键被终止时,它对应的键值对也就从映射中有效地移除了。

public static void weakHashMap(){
		Map<Integer,String> map = new HashMap<Integer,String>();
		Integer key=new Integer(1);
		String value="hashMap";
		map.put(key, value);
		System.out.println(map);
		key=null;
		System.gc();
		System.out.println(map+"\t"+map.size());//不会回收
		
		WeakHashMap<Integer,String> weakHashMap = new WeakHashMap<Integer,String>();
		Integer key2=new Integer(2);
		String value2="WeakHashMap";
		weakHashMap.put(key2, value2);
		System.out.println(weakHashMap);
		key2=null;
		System.gc();
		System.out.println(weakHashMap+"\t"+weakHashMap.size());//{}  0  会回收
		
	}

6.虚引用:就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之 关联的引用队列中。加入到队列中,可以在对象被回收是干点事。

public static void phantomRefMemoryEnough(){
		
		Object object = new Object();
		ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();
		PhantomReference<Object> phantomReference = new PhantomReference<Object>(object, referenceQueue);
		System.out.println(object);
		System.out.println(phantomReference.get());//永远获取为null
		System.out.println(referenceQueue.poll());//null
		object=null;
		System.gc();
		try {
			TimeUnit.MILLISECONDS.sleep(500);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		System.out.println(object);//null
		System.out.println(phantomReference.get());//null
		System.out.println(referenceQueue.poll());//加入到引用队列
	}

 7.四中引用的使用场景:现在存在一个图片缓存,第一次读取图片将其加载到缓存中,之后获取图片就比较快,但大量的图片加入到缓存中会导致OOM,为了解决这个问题,可以使用软引用或弱引用,实现思路:使用HashMap保存图片地址和相应图片关联的软引用关系,在内存不足时,JVM会自动回收这些缓存图片占用的内存空间,从而避免OOM。

8.四大引用总结:

当垃圾回收器回收时,某些对象会被回收,某些不会被回收。垃圾回收器会从根对象Object来标记存活的对象,然后将某些不可达的对象和一些引用的对象进行回收 

5.谈谈你对OOM的认识

1.java.lang.StackOverflowError(栈内存溢出):递归调用如果过跳出递归的条件设置不合理,很有可能出现栈内存溢出。StackOverflowError是一个Error。

2.java.lang.OutOfMemoryError:Java heap space(堆内存溢出):堆中对象太多,对被撑爆了导致堆内存溢出,OutOfMemoryError也是一个错误。

3.java.lang.OutOfMemoryError:GC overhead limit exceeded:大量的资源被用来GC,回收了少量的内存,GC做的是无用功。

//-Xmx10m -Xms10m -XX:+PrintGCDetails -XX:MaxDirectMemorySize=5m
	private static void gcOverHead() {
		int i=0;
		List<Object> list = new ArrayList<>();
		try {
			while(true){
				list.add(String.valueOf(i++).intern());
			}
		} catch (Exception e) {
			System.out.println("-------------------i="+i);
			e.printStackTrace();
		}
	}

4.java.lang.OutOfMemoryError:Direct buffer memory:创建Buffer对象时,可以选择从JVM堆中分配内存,也可以OS本地内存中分配,由于本地缓冲区避免了缓冲区复制,在性能上相对堆缓冲区有一定优势

  • JVM堆缓冲区:ByteBuffer.allocate(size)
  • OS本地内存:ByteBuffer.allocateDirect(size)

创建Buffer对象分配至JVM堆内存时,属于GC管辖,由于需要拷贝所以速度较慢,分配在OS本地内存,不属于GC管辖,不用拷贝速度较快,但如果不断创建本地内存,堆内存很少使用,那么JVM就不需要执行GC,DirectByteBuffer对象就不会被回收,这是堆内存充足,本地内存可能已经使用完毕,再次尝试分配内存就会出现OOM,一般在NIO时出现。

//-Xmx10m -Xms10m -XX:+PrintGCDetails -XX:MaxDirectMemorySize=5m
	private static void directBuffer() {
		System.out.println(sun.misc.VM.maxDirectMemory()/(double)1024/1024+"MB");
		try {
			Thread.sleep(3000);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		ByteBuffer allocate = ByteBuffer.allocateDirect(6*1024*1024);
	}

5. java.lang.OutOfMemoryError:unable to create new native thread:不能在创建线程,不同平台不同,在Linux平台下一个进程中最多创建1024(linux版本不同可能会有区别)个线程,一个进程中创建线程太多就会出这个异常。解决:降低程序的创建线程数,调大系统对最大线程数的限制。linux下调大线程数限制:

private static void maxThread() {
		for (int i = 0; ; i++) {
			new Thread(new Runnable() {
				@Override
				public void run() {
					try {
						Thread.sleep(Integer.MAX_VALUE);
					} catch (InterruptedException e) {
						e.printStackTrace();
					}
				}
			}).start();
			System.out.println(i);
		}
	}

6.  java.lang.OutOfMemoryError:Metaspqce:元空间(方法区)溢出,元空间初始大小21M左右,元空间不在虚拟机内部,而是使用本地内存,不断地向元空间创建对象,导致元空间内存溢出。

6.谈谈GC垃圾回收算法及垃圾回收器

1.标记-清除算法

最基础的垃圾收集算法,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成之后统一回收掉所有被标记的对象。标记-清除算法的缺点有两个:首先,效率问题,标记和清除效率都不高。其次,标记清除之后会产生大量的不连续的内存碎片,空间碎片太多会导致当程序需要为较大对象分配内存时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

2.复制算法

将可用内存按容量分成大小相等的两块,每次只使用其中一块,当这块内存使用完了,就将还存活的对象复制到另一块内存上去,然后把使用过的内存空间一次清理掉。这样使得每次都是对其中一块内存进行回收,内存分配时不用考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。复制算法的缺点显而易见,可使用的内存降为原来一半。

3.标记-整理算法

标记-整理算法在标记-清除算法基础上做了改进,标记阶段是相同的标记出所有需要回收的对象,在标记完成之后不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,在移动过程中清理掉可回收的对象,这个过程叫做整理。标记-整理算法相比标记-清除算法的优点是内存被整理以后不会产生大量不连续内存碎片问题。复制算法在对象存活率高的情况下就要执行较多的复制操作,效率将会变低,而在对象存活率高的情况下使用标记-整理算法效率会大大提高。

4.分代收集算法

根据内存中对象的存活周期不同,将内存划分为几块,java的虚拟机中一般把内存划分为新生代和年老代,当新创建对象时一般在新生代中分配内存空间,当新生代垃圾收集器回收几次之后仍然存活的对象会被移动到年老代内存中,当大对象在新生代中无法找到足够的连续内存时也直接在年老代中创建。现在的Java虚拟机就联合使用了分代复制、标记-清除和标记-整理算法,java虚拟机垃圾收集器关注的内存结构如下:

5.Serial(串行垃圾收集器):垃圾收集线程(单线程)执行时,程序线程需要停止,使用-XX:+UseSerialGC激活新生代Serial收集器,serial被激活后,老年代会配合Serial Old收集器

6.ParNew(并行垃圾收集器):多个垃圾收集线程执行来及收集,程序线程需要停止,使用-XX:+UseParNewGC激活新生代ParNew收集器,会配合老年代的Serial Old垃圾收集器使用(不推荐),但最常配合CMS使用,可以通过-XX:ParallelGCThreads设置并行收集器的垃圾收集线程数,默认开启和CPU相同的线程数

7.Parallel(并行垃圾收集器):新生代默认收集器,类似于ParNew收集器,使用复制算法,适用于新生代,配合老年代的Parallel Old(jdk8)/Serial Old(jdk6)收集器使用,使用-XX:+UseParallelGC/-XX:+UseParallelOldGC(可互相激活)激活

8.Parallel Old(并行垃圾收集器):老年代使用,配合新生代的Parallel收集器使用

9.Serial Old(串行垃圾收集器):老年代使用,JDK1.8被优化,不在被使用。

10.CMS(并发标记清除垃圾收集器):多条垃圾收集线程执行收集垃圾,程序线程不用停止,使用-XX:+UseConcMarkSweepGC开启,老年代使用CMS收集器后,新生代会使用ParNew收集器,老年代就配合CMS+Serial Old收集器(ParNew+CMS+Serial Old)。

收集过程

  • 初始标记(CMS inital mark):需要“stop the world”,但只标记一下GC Roots能直接关联的对象,速度很快。
  • 并发标记(CMS concurrent mark):是GC Roots Tracing的过程,花费时间长
  • 重新标记(CMS remark):*需要“stop the world”,是为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
  • 并发清除(CMS concurrent sweep):是并发清除无用对象。

11.G1收集器:G1是一款面向服务器端应用的垃圾收集器,使用G1收集器时,Java堆的内存布局是整个规划为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region的集合,避免了全内存的GC操作。在对上的使用,G1并不要求对象的存储一定是物理上的连续,只要逻辑连续即可,每一个region不会固定的为某个分代服务,可以根据需求在老年代及新生代之间切换。启动时可以通过-XX:G1HeapRegionSize指定每个Region的大小(默认1-32M,必须是2^n大小),默认将整个堆划分为2048个region,最大支持大小为32*2048=64G。

G1算法将堆内存划分为若干个Region,他任然属于分代收集,这些Region包含新生代,新生代的垃圾收集依然采用Stop the World的方法,将存活的对象拷贝到老年代或者Survivor空间,这些Region的一部分包含老年代,G1收集器通过对象从一个区域复制到另一个区域,完成清理工作,这意味着,在正常处理工程中,G1完成堆的压缩(至少是部分堆的压缩),这样也就不会有CMS内存碎片问题。

回收前后对比:

   

回收步骤:

  • 初始标记(Initial Marking):标记GC Roots能够直接关联到的对象,并且修改TAMS的值,能在正确可用的Region中创建对象,这阶段需要停顿线程,而且耗时很短。
  • 并发标记(Concurrent Marking):从GC Roots开始堆中对象进行可达性分析,找出存活的对象,这个时间耗时比较长,但可与用户程序并行执行。
  • 最终标记(Final Marking):为了修正和正在并发标记期间因用户程序继续运行而导致标记产生变动的那一部分没有标记记录,虚拟机将这一段对象变法记录在线程Rememberred Set logs里面,最终标记阶段需要把Remembered Set logs 的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并发执行。
  • 筛选回收(Live Data Counting and Evacuation):对各个Region的回收截止和成本进行排序,根据用户期望的GC停顿时间来制定回收计划,这阶段可以做到和用户程序一起并发执行,但是因为值回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅度提高手机效率。

配置参数:

  • -XX:+UseG1Gc:配置使用G1垃圾回收器
  • -XX:G1HeapRegionSize:配置每隔Region大小
  • -XX:MaxGcPauseMillis:最大GC停顿时间

和CMS相比的优势:

  • G1不存在内存碎片
  • 可以精确控制GC停顿时间,用户可以自己指定,JVM会尽量接近这个时间

7种垃圾收集器详解:https://blog.csdn.net/u012998254/article/details/81635902

7.如何查看默认垃圾收集器,如何配置垃圾收集器,如何选择合适的垃圾收集器

1.查看默认垃圾收集器:

java -XX:+PrintCommandLineFlags -version

2.新生代和老年代使用不同收集器

3.配置垃圾收集器:参考6

4.如何选择合适的垃圾收集器:

  • 单CPU或小内存,单机程序:-XX:+UseSerialGC
  • 多CPU需要大吞吐量,后台计算型应用:-XX:+UseParallelGC或-XX:+UseParallelOldGC
  • 多CPU追求低停顿时间,需要快速响应如互联网应用:-XX:+UseConcMarkSweepGCl

8.JVM在微服务中调参

java -server JVM参数 -jar xxx.jar

9.生产环境服务器变慢了,谈谈诊断思路和性能评估

linux命令:

1.top/uptime:查看整机系统性能,重点查看load average和列表的CPU MEM列

2.vmstat:查看CPU性能,每2秒采样一次,共采样3次,vmstat -n 2 3

查看所有CPU核信息:mpstat -P ALL 采样间隔时间

查看每个进程使用CPU用量分解信息:pidstat -u 采样间隔时间 -p 进程编号

3.free:查看内存 -g(取值为G) -m(取值为M)

查看某个进程对内存的消耗:pidstat -p 进程号 -r 采样间隔时间

4.df:查看硬盘

5.iostat:查看磁盘IO  iostat -xdk 采样间隔 采样次数

查看某个进程的IO:pidstat -d 采样间隔 -p 进程号

6.ifstat:查看网络IO

9.生产环境出现CPU占用过高,谈谈分析思路及定位

  • 先使用top命令找出CPU占比最高的
  • ps -efjps -l 进一步定位,得知是什么样的程序惹事的。
  • 定位具体的线程或代码,ps -mp 进程编号 -o THREAD,tid,time(-m显示所有线程,-pPid进程使用的CPU时间,-o后是用户自定义格式,这里使用线程id及线程占用CPU时间)

  • 将需要的线程id转换为16进制格式(英文小写格式):printf '%x\n' 问题线程id

  • jstack 进程id | grep tid(16进制线程id小写英文) -A60(-A60获取前60行信息):找到问题项目包名及代码行号即可

10.常用JVM监控工具

1.jps:java process status, jps是用于查看有权访问的hotspot虚拟机的进程. 当未指定hostid时,默认查看本机jvm进程,否者查看指定的hostid机器上的jvm进程,此时hostid所指机器必须开启jstatd服务。 jps可以列出jvm进程lvmid,主类类名,main函数参数, jvm参数,jar名称等信息。

命令: jps [options] [hostid]

  • options:命令选项,用来对输出格式进行控制
  • hostid:指定特定主机,可以是ip地址和域名, 也可以指定具体协议,端口。
  • [protocol:][[//]hostname][:port][/servername]

命令选项及功能:

  • 没添加option的时候,默认列出VM标示符号和简单的class或jar名称
  • -m:输出主函数传入的参数.
  • -l: 输出应用程序主类完整package名称或jar完整名称.
  • -v: 列出jvm参数

2.jstat:jstat命令可以查看堆内存各部分的使用量,以及加载类的数量,配合jps使用

命令:jstat option 进程id [监控次数] [间隔时间毫秒值]

option选项:

  • -class 显示ClassLoad的相关信息;
  • -compiler 显示JIT编译的相关信息;
  • -gc 显示和gc相关的堆信息;
  • -gccapacity 显示各个代的容量以及使用情况;
  • -gcmetacapacity 显示metaspace的大小
  • -gcnew 显示新生代信息;
  • -gcnewcapacity 显示新生代大小和使用情况;
  • -gcold 显示老年代和永久代的信息;
  • -gcoldcapacity 显示老年代的大小;
  • -gcutil 显示垃圾收集信息;
  • -gccause 显示垃圾回收的相关信息(通-gcutil),同时显示最后一次或当前正在发生的垃圾回收的诱因;
  • -printcompilation 输出JIT编译的方法信息;

3.jinfo:查看运行中jvm的全部参数,还可以设置部分参数,一般配合jps使用

命令:jinfo [option] 进程id

option选项:

  • no option   输出全部的参数和系统属性
  • -flag  name  输出对应名称的参数
  • -flag [+/-]name  开启或者关闭对应名称的JVM参数
  • -flag name=value  设定对应名称的JVM参数
  • -flags  输出全部的参数
  • -sysprops  输出系统属性

4.jmap:Java Memory Map,主要用于打印指定Java进程(或核心文件、远程调试服务器)的共享对象内存映射或堆内存细节。

命令:jmap [option] 进程id

option选项:

  • no option: 查看进程的内存映像信息,类似 Solaris pmap 命令。
  • heap: 显示Java堆详细信息
  • histo[:live]: 显示堆中对象的统计信息
  • clstats:打印类加载器信息
  • finalizerinfo: 显示在F-Queue队列等待Finalizer线程执行finalizer方法的对象
  • dump:<dump-options>:生成堆转储快照,jmap -dump:format=b,file=heapdump.phrof pid
  • F: 当-dump没有响应时,使用-dump或者-histo参数. 在这个模式下,live子参数无效.
  • help:打印帮助信息
  • J<flag>:指定传递给运行jmap的JVM的参数

5.jhat:主要是用来分析java堆的命令,可以将堆中的对象以html的形式显示出来,包括对象的数量,大小等等,并支持对象查询语言。

  • 通过jmap生成转储快照
  • 通过jhat分析:jhat 文件地址
  • 访问http://ip:7000/

6.jstack:jstack用于生成java虚拟机当前时刻的线程快照。线程快照是当前java虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等。

命令:jstack [option] 进程id

option选项:

  • -F:强制打印
  • -m:显示本地方法栈
  • -l:打印关于锁的附加信息

7.jconsole:参考:https://blog.csdn.net/qq_31156277/article/details/80035430

8.VisualVM:下载地址:http://visualvm.github.io/download.html

猜你喜欢

转载自blog.csdn.net/qq_36625757/article/details/89890856