zoukankan      html  css  js  c++  java
  • 《深入理解JAVA虚拟机》笔记

    第1章 走进Java
    1.1 Java技术体系
    Java技术体系包括:Java程序设计语言、各种硬件平台上的Java虚拟机、Class文件格式、Java API类库、第三方Java类库。
    JDK:Java Development Kit,用于支持Java程序开发的最小环境,包括:Java程序设计语言、Java虚拟机、Java API类库。
    JRE:Java Runtime Environment,支持Java程序运行的标准环境,包括:JAVA SE API子集、Java虚拟机。
     
    1.2 Java发展史
    1991年4月 Oak橡树,java语言的前身。
    1995年5月 Oak改名为Java,第一次提出“Write Once,Run Anywhere”的口号。
    1996年1月23日 JDK 1.0发布,提供了一个纯解释执行的Java虚拟机实现(Sun Classic VM)。
    1999年4月 HotSpot虚拟机发布,为Sun公司收购的一家小公司开发,成为JDK 1.3及以后版本的默认虚拟机。
     
    1.3 Java虚拟机发展史
    Dalvik VM:Android平台的核心组成部分,它并不是一个虚拟机,没有遵循Java虚拟机规范,不能直接执行Java的Class文件,使用的是寄存器架构而不是JVM中常见的栈架构。它执行的dex文件可以通过class文件转换而来。
     
    1.4 展望Java技术的未来
    模块化、混合语言、多核并行、进一步丰富语法、64位虚拟机。
     
     
    第2章 Java内存区域与内存溢出异常
    2.1 JVM运行时数据区
    (1)程序计数器
    线程私有的一块较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器。
    (2)虚拟机栈
    VM Stack是线程私有的,生命周期与线程相同。描述的是java方法执行的内存模型:每个方法执行时会创建一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态链接、方法出口等。
    JAVA内存粗糙划分为堆内存(Heap)和栈内存(Stack)。此栈内存即虚拟机栈,当栈深度不足,会抛出StackOverflowError异常。
    局部变量表所需的内存空间大小在编译期完成分配并确定,方法运行期间不会改变局部变量表的大小。
    (3)本地方法栈
    虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。
    Sun HotSpot虚拟机会将本地方法栈和虚拟机栈合二为一。
    (4)Java堆
    Java Heap是JVM所管理的内存中最大一块,被所有线程共享,唯一目的就是存放对象实例。
    java堆是垃圾收集器管理的主要区域,也叫GC堆。主要分为三种空间:新生代(Eden、From Survivor、To Survivor)和老年代,以及多个线程私有的分配缓冲区TLAB。
    (5)方法区
    Method Area,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
    HotSpot虚拟机为了管理这部分内存,将GC分代收集扩展至方法区,主要针对常量池的回收和对类型的卸载,并称为永久代(Permanent Generation),而其他虚拟机没有永久代概念。
    (6)运行时常量池
    Runtime Constant Pool,是方法区的一部分。用于存放编译期生成的各种字面量和符号引用。也可以在运行期将新的常量放入池中,如String类的intern()方法。
    (7)直接内存
    Direct Memory,不是虚拟机运行时数据区的一部分,也不是JVM规范定义的内存区域。JDK 1.4引入NIO 类,是一种基于通道Channel和缓冲区Buffer的IO方式,它可以使用Native函数直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样的好处是避免了在Java堆和Native堆中来回复制数据,从而显著提高性能。
    本地直接内存的分配不会受到Java堆大小的限制。
     
    2.2 HotSpot虚拟机对象探秘
    (1)对象的创建
    虚拟机遇到一条new指令时,首先将检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用所代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
    在类加载检查通过后,接下来虚拟机将为新生对象分配内存。有如下两种分配方式:
    ①指针碰撞
    仅当Java堆内存是规整的,已有和空闲内存各在一边,通过将分界点指针向空闲空间挪动来分配。如Serial、ParNew等带Compact压缩过程的收集器采用此方式。
    ②空闲列表
    当Java堆内存不是规整的,如CMS等基于Mark-Sweep算法的收集器采用此方式。
     
    另外,在并发环境下为了保证线程安全的分配,有两种方法:
    ①通过循环CAS来保证分配动作的原子性。
    ②在TLAB(本地线程分配缓冲)中分配,线程隔离,当TLAB空间不足时,才需要方法1的同步锁定。
     
    内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头)。接下来,JVM对对象头信息进行设置,如:类元数据信息、对象哈希码、对象GC分代年龄等。
    上述工作都完成后,才调用对象的init方法开始初始化。
     
    (2)对象的内存布局
    HotSpot虚拟机中,对象在内存的存储布局分为3块区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。
    其中对象头包含2~3个字,第一个字为Mark Word,存储对象自身的运行时数据,如哈希码,GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等。第二个字为类型元数据的指针。第三个字为数组长度(当对象是数组才存在)。
    对齐填充:虚拟机要求对象起始地址必须是8字节的倍数,因此当实例数据部分没有对齐时,就需要对齐填充。
     
    (3)对象的访问定位
    java程序需要通过栈上的reference类型数据去访问堆上的具体对象,目前有两种访问方式:
    ①使用句柄访问
    java堆中会划分出一块内存来作为句柄池,reference中存储的就是句柄地址,句柄包含对象实例数据和类型数据的地址信息。
    最大优点:reference存储稳定的句柄地址,GC移动对象时只需改变句柄内容,不需要修改reference。
    ②使用直接指针访问
    reference中存储的直接就是对象地址,而对象实例数据在对象头第二个字中包含对象类型数据的地址信息。HotSpot虚拟机对象定位采用直接指针访问。
    最大优点:速度更快,节省一次指针定位的开销。
     
    2.3 虚拟机OutOfMemoryError异常
    ①-Xms 设置Java堆得最小值;-Xmx 设置Java堆最大值。
    ②-XX:+HeapDumpOnOutOfMemoryError 让虚拟机在OOM时Dump出当前的内存转储快照。
    ③-Xss 设置虚拟机栈大小。
    ④-XX:PermSize 设置方法区大小;-XX:MaxPermSize 设置方法区最大容量。在JDK1.8中已无效,通过-XX:MetaspaceSize和-XX:MaxMetaspaceSize来代替。
     
    2.4 String.intern()
    String.intern()是一个Native方法,作用是:如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象;否则,将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。
    当JDK1.6时,因为常量池分配在永久代中,intern()会把首次遇到的字符串实例复制到永久代,返回永久代中这个字符串实例的引用。
    当JDK1.7时,因为常量池没有分配在永久代,而是移到java堆。intern()不会再复制,只是在常量池中记录首次出现的实例引用。
    当JDK1.8时,永久代因GC回收效率太低且复杂度高、永久代字符串容易溢出等原因被完全移除,采用元空间(Metaspace)区域来代替。
    常见试题解答:
    Q:下列程序的输出结果:
    String s1 = “abc”;
    String s2 = “abc”;
    System.out.println(s1 == s2);
    A:true,均指向常量池中对象。
     
    Q:下列程序的输出结果:
    String s1 = new String(“abc”);
    String s2 = new String(“abc”);
    System.out.println(s1 == s2);
    A:false,两个引用指向堆中的不同对象。
     
    Q:下列程序的输出结果:
    String s1 = “abc”;
    String s2 = “a”;
    String s3 = “bc”;
    String s4 = s2 + s3;
    System.out.println(s1 == s4);
    A:false,因为s2+s3实际上是使用StringBuilder.append来完成,会生成不同的对象。
     
    Q:下列程序的输出结果:
    String s1 = “abc”;
    final String s2 = “a”;
    final String s3 = “bc”;
    String s4 = s2 + s3;
    System.out.println(s1 == s4);
    A:true,因为final变量在编译后会直接替换成对应的值,所以实际上等于s4=”a”+”bc”,而这种情况下,编译器会直接合并为s4=”abc”,所以最终s1==s4。
     
    Q:下列程序的输出结果:
    String s = new String(“abc”);
    String s1 = “abc”;
    String s2 = new String(“abc”);
    System.out.println(s == s1.intern());
    System.out.println(s == s2.intern());
    System.out.println(s1 == s2.intern());
    A:false,false,true。
     
     
    第3章 垃圾收集器与内存分配策略
    3.1 对象已死吗
    GC垃圾收集之前,有两种方法判断对象是否“死去”(即不可能再被任何途径使用的对象):
    ①引用计数法
    优点:简单,判断效率高。
    缺点:很难解决对象之间相互循环引用问题,主流JVM全都没有采用此方法。
    ②可达性分析算法
    基本思想:当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。
    可作为起始点GC Roots的对象有四种:虚拟机栈(栈帧中的本地变量表)中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI(即Native方法)引用的对象。
     
    java将引用按强度依次递减,分为四种:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)。
    强引用:用new创建,只要存在,GC就不会回收。
    软引用:描述一些还有用但并非必需的对象。在即将OOM之前,会对软引用对象第二次回收,如果还没有足够内存才抛出异常。
    弱引用:描述非必需对象。只能生存到下次GC之前。
    虚引用:完全不会对生存时间有影响,也无法通过虚引用获取对象。唯一目的:对象被GC时收到一个系统通知。
     
    即使在可达性分析算法中不可达对象,也并不是“非死不可”,要宣告死亡,至少要经过两次标记过程:
    ①第一次标记
    如果对象不可达,对象会被第一次标记并且进行一次筛选,筛选条件是此对象有没有必要执行finalize()方法。当已执行过或方法没覆盖,则视为没有必要执行。
    ②第二次标记
    当对象有必要执行finalize方法时,会将对象放入F-Queue队列,由低优先级的Finalizer线程去执行但不会等到方法结束(防止方法内部死循环),稍后GC会对队列中的对象进行第二次标记,如果对象在finalize方法中与引用链中任何对象建立关联,则成功解救自己。否则被真正回收。
    因为finalize方法不确定是否执行完成,不确定性大,故不建议用其来做清理工作,应该用try-finally语句块代替。
     
    方法区类回收,类需要同时满足三个条件才能算是“无用的类”:
    ①该类所有的实例都已经被回收。
    ②加载该类的ClassLoader已经被回收。
    ③该类对应的java.lang.Class对象没有在任何地方被引用,无法通过反射访问该类。
     
    3.2 垃圾收集算法
    (1)标记-清除算法
    Mark-Sweep算法是最基础的GC算法。后续的收集算法都是基于该算法思路并对其不足进行改进。
    缺点:①标记和清除两个过程效率都不高;②清除后会产生大量不连续的内存碎片。内存碎片太多可能导致当需要分配较大对象时,无法找到足够连续内存而不得不提前触发一次GC。
     
    (2)复制算法
    将内存按容量划分为大小相等的两块,每次只使用其中一块,当这块内存用完了,就将还存活的对象复制到另外一块上,然后再把已使用的内存空间一次清理掉。
    优点:简单高效。
    缺点:内存只用一半,浪费严重。
     
    现在的商业虚拟机都采用复制算法来回收新生代。因为新生代中对象98%都是“朝生夕死”,故不必按1:1平分内存,而将内存分为一块较大的Eden空间和两块较小的Survivor空间,当回收时,将Eden和From Survivor中还存活的对象一次性复制到To Survivor中,默认比例是8:1:1,只有10%的内存被浪费。当To Survivor空间不够用时,需要额外的空间(老年代)进行分配担保。
     
    (3)标记-整理算法
    复制算法在对象存活率较高时需要进行较多的复制操作,效率将会变低,所以老年代不能直接采用复制算法。
    Mark-Compact算法是根据老年代特点提出的,标记阶段与标记-清除算法相同,整理阶段不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
     
    (4)分代收集算法
    当前商业虚拟机垃圾收集都采用“分代收集”算法。根据对象存活周期的不同将内存划分为几块。一般讲Java堆分为新生代和老年代,新生代用复制算法,老年代用“标记-清理”或“标记-整理”算法。
     
    3.3 HotSpot算法实现
    枚举根节点:可达性分析对执行时间的敏感还体现在GC停顿上,因为这项分析工作必须在一个能确保一致性的快照中进行,这点导致GC进行时必须停顿所有Java执行线程(Stop The World),即使在号称几乎不会停顿的CMS收集器中,枚举CG Roots根节点时也是必须要停顿的。
    在OopMap数据结构协助下,HotSpot可以快速准确的完成CG Roots枚举,因为类加载完成时,HotSpot就把对象内什么偏移量上是什么类型的数据都计算出来,另外JIT编译时也会在特定位置记录栈和寄存器中哪些位置是引用。
     
    安全点:Safepoint,即程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。
    当GC发生时如何让所有线程都跑到最近的安全点上再停顿下来?有如下两种方案:
    ①抢先式中断
    不需要线程执行代码主动配合,GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复该线程,让它跑到安全点上。
    现在几乎没有虚拟机采用此方法。
    ②主动式中断
    GC发生时,简单设置一个标志,各个线程执行到安全点时主动去轮询这个标志,发现中断标志为真就自己中断挂起。
     
    安全区域:Safe Region,时指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。
    因为线程处于Sleep状态或者Blocked状态时,线程无法响应JVM的中断请求来走到安全的地方去中断挂起。此时就需要安全区域来解决。
     
    3.4 垃圾收集器
    收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现。JVM规范对垃圾收集器如何实现没有任何规定。目前还没有最好或万能的收集器。
    上面有7中收集器,分为两块,上面为新生代收集器,下面是老年代收集器。如果两个收集器之间存在连线,就说明它们可以搭配使用。
    (1)Serial(串行GC)收集器
    Serial收集器是一个新生代收集器,单线程执行,使用复制算法。它在进行垃圾收集时,它不仅只会使用一个CPU或者一条收集线程去完成垃圾收集作,而且必须暂停其他所有的工作线程(用户线程),直到它收集完成。
    是Jvm client模式下默认的新生代收集器。对于限定单个CPU的环境来说,简单高效,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率,因此是运行在Client模式下的虚拟机的不错选择(比如桌面应用场景)。
    Serial/Serial Old收集器运行示意图(表示Serial和Serial Old搭配使用):
    (2)ParNew(并行GC)收集器
    ParNew收集器其实就是serial收集器的多线程版本,使用复制算法。除了使用多条线程进行垃圾收集之外,其余行为与Serial收集器一样。
    它是运行在Service模式下虚拟机中首选的新生代收集器,其中一个与性能无关的原因就是除了Serial收集器外,目前只有ParNew收集器能与CMS收集器配合工作。
    ParNew收集器在单CPU环境中绝对没有Serial的效果好,由于存在线程交互的开销,该收集器在超线程技术实现的双CPU中都不能一定超过Serial收集器。默认开启的垃圾收集器线程数就是CPU数量,可通过-XX:parallelGCThreads参数来限制收集器线程数。
    ParNew/Serial Old收集器运行示意图(表示ParNew和Serial Old搭配使用):
    (3)Parallel Scavenge(并行回收GC)收集器
    Parallel Scavenge收集器也是一个新生代收集器,它也是使用复制算法的收集器,又是并行多线程收集器。parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而parallel Scavenge收集器的目标则是达到一个可控制的吞吐量。吞吐量= 程序运行时间/(程序运行时间 + 垃圾收集时间),虚拟机总共运行了100分钟。其中垃圾收集花掉1分钟,那吞吐量就是99%。
    短停顿时间适合和用户交互的程序,体验好。高吞吐量适合高效利用CPU,主要用于后台运算不需要太多交互。
    提供了两个参数来精确控制吞吐量:1.最大垃圾收集器停顿时间(-XX:MaxGCPauseMillis 大于0的毫秒数,停顿时间小了就要牺牲相应的吞吐量和新生代空间),2.设置吞吐量大小(-XX:GCTimeRatio 大于0小于100的整数,默认99,也就是允许最大1%的垃圾回收时间)。
    还有一个参数表示自适应调节策略(GC Ergonomics)(-XX:UseAdaptiveSizePolicy)。就不用手动设置新生代大小(-Xmn)、Eden和Survivor区的比例(-XX:SurvivorRatio)今生老年代对象大小(-XX:PretenureSizeThreshold),会根据当前系统的运行情况手机监控信息,动态调整停顿时间和吞吐量大小。也是其与PreNew收集器的一个重要区别,也是其无法与CMS收集器搭配使用的原因(CMS收集器尽可能地缩短垃圾收集时用户线程的停顿时间,以提升交互体验)。
    Parallel Scavenge/Parallel Old收集器运行示意图(表示Parallel Scavenge和Parallel Old搭配使用):
    (4)Serial Old(串行GC)收集器
    Serial Old是Serial收集器的老年代版本,它同样使用一个单线程执行收集,使用“标记-整理”算法。主要使用在Client模式下的虚拟机。
    如果在Service模式下使用:1.一种是在JDK1.5以及之前的版本中与Parallel Scavenge收集器搭配使用,因为那时还没有Parallel Old老年代收集器搭配;2.另一种就是作为CMS收集器的后备预案,在并发收集发生Concurrent Model Failure时使用。
    (5)Parallel Old(并行GC)收集器
    Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法,JDK1.6才提供。
    由于之前有一个Parallel Scavenge新生代收集器,,但是却无老年代收集器与之完美结合,只能采用Serial Old老年代收集器,但是由于Serial Old收集器在服务端应用性能上低下(毕竟单线程,多CPU浪费了),其吞吐量反而不一定有PreNew+CMS组合。
    Parallel Scavenge/Parallel Old收集器运行示意图(表示Parallel Scavenge和Parallel Old搭配使用):
    (6)CMS(并发GC)收集器
    CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。CMS收集器是HotSpot虚拟机中的一款真正意义上的并发收集器,第一次实现了让垃圾回收线程和用户线程(基本上)同时工作。用CMS收集老年代的时候,新生代只能选择Serial或者ParNew收集器。
    CMS收集器是基于“标记-清除”算法实现的,整个收集过程大致分为4个步骤:
    ①.初始标记(CMS initial mark)
    ②.并发标记(CMS concurrenr mark)
    ③.重新标记(CMS remark)
    ④.并发清除(CMS concurrent sweep)
    其中初始标记、重新标记这两个步骤任然需要停顿其他用户线程(Stop The World)初始标记仅仅只是标记出GC ROOTS能直接关联到的对象,速度很快,并发标记阶段是进行GC ROOTS 根搜索算法阶段,会判定对象是否存活。而重新标记阶段则是为了修正并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间会被初始标记阶段稍长,但比并发标记阶段要短。
    由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以整体来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
    CMS收集器运行示意图:
    CMS收集器的优点:并发收集、低停顿,但是CMS还远远达不到完美,其主要有三个显著缺点:
    1.CMS收集器对CPU资源非常敏感。在并发(并发标记、并发清除)阶段,虽然不会导致用户线程停顿,但是会占用CPU资源而导致应用程序变慢,总吞吐量下降。CMS默认启动的回收线程数是:(CPU数量+3) / 4。收集器线程所占用的CPU数量为:(CPU+3)/4=0.25+3/(4*CPU)。因此这时垃圾收集器始终不会占用少于25%的CPU,因此当进行并发阶段时,虽然用户线程可以跑,但是很缓慢,特别是双核CPU的时候,已经占用了5/8的CPU,吞吐量会很低。为了解决这种情况,产生了“增量式并发收集器”(Incremental Concurrent Mark Sweep/i-CMS)。就是采用抢占方式来模拟多任务机制,就是在并发(并发标记、并发清除)阶段,让GC线程、用户线程交替执行,尽量减少GC线程独占CPU,这样垃圾收集过程更长,但是对用户程序影响小一些。实际上i-CMS效果很一般,目前已经被声明为“deprecated”。
    2.CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure“,失败后而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在运行,伴随程序的运行自热会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在本次收集中处理它们,只好留待下一次GC时将其清理掉。这一部分垃圾称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,即需要预留足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分内存空间提供并发收集时的程序运作使用。在默认设置下,CMS收集器在老年代使用了68%的空间时就会被激活,也可以通过参数-XX:CMSInitiatingOccupancyFraction的值来提高触发百分比,以降低内存回收次数提高性能。JDK1.6中,CMS收集器的启动阈值已经提升到92%。要是CMS运行期间预留的内存无法满足程序其他线程需要,就会出现“Concurrent Mode Failure”失败,这时候虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数-XX:CMSInitiatingOccupancyFraction设置的过高将会很容易导致“Concurrent Mode Failure”失败,性能反而降低。
    3.最后一个缺点,CMS是基于“标记-清除”算法实现的收集器,使用“标记-清除”算法收集后,会产生大量碎片。空间碎片太多时,将会给对象分配带来很多麻烦,比如说大对象,内存空间找不到连续的空间来分配不得不提前触发一次Full GC。为了解决这个问题,CMS收集器提供了一个-XX:UseCMSCompactAtFullCollection开关参数,用于在Full GC之后增加一个内存碎片的合并整理过程,但是内存整理过程是无法并发的,因此解决了空间碎片问题,却使停顿时间变长。还可通过-XX:CMSFullGCBeforeCompaction参数设置执行多少次不压缩的Full GC之后,跟着来一次碎片整理过程(默认值是0,表示每次进入Full GC时都进行碎片整理)。
     
    (7)G1收集器
    G1(Garbage First)收集器是JDK1.7提供的一个新的面向服务端应用的垃圾收集器,其目标就是替换掉JDK1.5发布的CMS收集器。其优点有:
    1.并发与并行:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU(CPU或CPU核心)来缩短停顿(Stop The World)时间。
    2.分代收集:G1不需要与其他收集器配合就能独立管理整个GC堆,但他能够采用不同方式去处理新建对象和已经存活了一段时间、熬过多次GC的老年代对象以获取更好收集效果。
    3.空间整合:从整体来看是基于“标记-整理”算法实现,从局部(两个Region之间)来看是基于“复制”算法实现的,但是都意味着G1运行期间不会产生内存碎片空间,更健康,遇到大对象时,不会因为没有连续空间而进行下一次GC,甚至一次Full GC。
    4.可预测的停顿:降低停顿是G1和CMS共同关注点,但G1除了追求低停顿,还能建立可预测的停顿模型,可以明确地指定在一个长度为M的时间片内,消耗在垃圾收集的时间不超过N毫秒
    5.跨代特性:之前的收集器进行收集的范围都是整个新生代或老年代,而G1扩展到整个Java堆(包括新生代,老年代)。
     
    那么是怎么实现的呢?
    1.如何实现新生代和老年代全范围收集:其实它的Java堆布局就不同于其余收集器,它将整个Java堆划分为多个大小相等的独立区域(Region),仍然保留新生代和老年代的概念,可是不是物理隔离的,都是一部分Region(不需要连续)的集合。
    2.如何建立可预测的停顿时间模型:是因为有了独立区域Region的存在,就避免在Java堆中进行全区域的垃圾收集,G1跟踪各个Region里面的垃圾堆积的价值大小(回收可以获得的空间大小和回收所需要的时间的经验值),后台维护一个优先队列,根据每次允许的收集时间,优先回收价值最大的Region(Garbage-First理念)。因此使用Region划分内存空间以及有优先级的区域回收方式,保证了有限时间获得尽可能高的收集效率。
    3.如何保证垃圾回收真的在Region区域进行而不会扩散到全局:由于Region并不是孤立的,一个Region的对象可以被整个Java堆的任意其余Region的对象所引用,在做可达性判定确定对象是否存活时,仍然会关联到Java堆的任意对象,G1中这种情况特别明显。而以前在别的分代收集里面,新生代规模要比老年代小许多,新生代收集也频繁得多,也会涉及到扫描新生代时也会扫描老年代的情况,相反亦然。解决:G1收集器Region之间的对象引用以及新生代和老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描。G1中每个Region都有一个与之对应的Remembered Set,虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中(分代的例子中就检查是否老年代对象引用了新生代的对象),如果是则通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set之中,当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可避免全堆扫描。
    忽略Remembered Set的维护,G1的运行步骤可简单描述为:
    ①.初始标记(Initial Marking)
    ②.并发标记(Concurrenr Marking)
    ③.最终标记(Final Marking)
    ④.筛选回收(Live Data Counting And Evacution)
     
    1.初始标记:初始标记仅仅标记GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新的对象。这阶段需要停顿线程,不可并行执行,但是时间很短。
    2.并发标记:此阶段是从GC Roots开始对堆中对象进行可达性分析,找出存活对象,此阶段时间较长可与用户程序并发执行。
    3.最终标记:此阶段是为了修正在并发标记期间因为用户线程继续运行而导致标记产生变动的那一份标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这段时间需要停顿线程,但是可并行执行。
    4.筛选回收:对各个Region的回收价值和成本进行排序,根据用户期望的GC停顿时间来制定回收计划。
     
    如果现有的垃圾收集器没有出现任何问题,没有任何理由去选择G1,如果应用追求低停顿,G1可选择,如果追求吞吐量,和Parallel Scavenge/Parallel Old组合相比,G1并没有特别的优势。
     
    垃圾收集器参数总结
    -XX:+<option> 启用选项
    -XX:-<option> 不启用选项
    -XX:<option>=<number>
    -XX:<option>=<string>
     
    Client、Server模式默认GC:
     
    3.5 理解GC日志
    “[Full GC” 只要带有Full,则说明这次GC是发生了Stop-The-World的。
     
    3.6 内存分配与回收策略
    新生代GC:Minor GC,朝生夕灭,非常频繁,回收速度较快。
    老年代GC:Major GC、Full GC,经常会伴随至少一次Minor GC,速度比Minor GC慢10倍以上。
    相关规则如下:
    ①对象优先在Eden分配,当Eden没有足够空间进行分配时,虚拟机将发起一次Minor GC。
    ②大对象(如字符串、数组)直接进入老年代。最怕遇到一群朝生夕灭的短命大对象。大小阈值由JVM参数控制。
    ③长期存活的对象将进入老年代。对象GC年龄阈值由JVM参数控制。
    ④动态对象年龄判断。不是必须超过年龄阈值才能进入老年代,如果在Survivor空间中相同年龄所有对象大小总和大于Survivor空间一半,年龄大于等于该年龄的对象就可以直接进入老年代。
    ⑤只要老年代的连续空间小于新生代对象总大小或者历次晋升的平均大小,就会进行Full GC,否则只会进行Minor GC。
     
     
    第4章 虚拟机性能监控与故障处理工具
    4.1 JDK的命令行工具
    (1)JPS
    jps主要用来输出JVM中运行的进程状态信息。语法格式如下: jps [options] [hostid] 。
    命令行参数选项说明如下:
    -q 不输出类名、Jar名和传入main方法的参数
    -m 输出传入main方法的参数
    -l 输出main类或Jar的全限名
    -v 输出传入JVM的参数
     
    (2)jstack
    jstack主要用来查看某个Java进程内的线程堆栈信息。语法格式如下:
    jstack [option] pid
    jstack [option] executable core
    jstack [option] [server-id@]remote-hostname-or-ip
    命令行参数选项说明如下:
    -l long listings,会打印出额外的锁信息,在发生死锁时可以用jstack -l pid来观察锁持有情况
    -m mixed mode,不仅会输出Java堆栈信息,还会输出C/C++堆栈信息(比如Native方法)
     
    (3)jmap(Memory Map)和jhat(Java Heap Analysis Tool)
    jmap用来查看堆内存使用状况,一般结合jhat使用。jmap语法格式如下:
    jmap [option] pid
    jmap [option] executable core
    jmap [option] [server-id@]remote-hostname-or-ip
    如果运行在64位JVM上,可能需要指定-J-d64命令选项参数。
    ①jmap -permstat pid
    打印进程的类加载器和类加载器加载的持久代对象信息,输出:类加载器名称、对象是否存活(不可靠)、对象地址、父类加载器、已加载的类大小等信息。
    ②jmap -heap pid
    查看进程堆内存使用情况,包括使用的GC算法、堆配置参数和各代中堆内存使用情况。
    ③jmap -histo[:live] pid
    查看堆内存中的对象数目、大小统计直方图,如果带上live则只统计活对象。
    ④jmap -dump:format=b,file=dumpFileName pid
    用jmap把进程内存使用情况dump到文件中,再用jhat、MAT、VisualVM等工具查看。
     
    (4)jstat(JVM统计监测工具)
    语法格式如下:jstat [ generalOption | outputOptions vmid [interval[s|ms] [count]] ] 。
    vmid是Java虚拟机ID,在Linux/Unix系统上一般就是进程ID。interval是采样时间间隔。count是采样数目。
    ①jstat -gc 21711 250 4
    输出的是GC信息,采样时间间隔为250ms,采样数为4。
     
    (5)jinfo
    java配置信息工具,实时查看和调整虚拟机各项参数。
     
    (6)hprof(Heap/CPU Profiling Tool)
    hprof能够展现CPU使用率,统计堆内存使用情况。语法格式如下:
    java -agentlib:hprof[=options] ToBeProfiledClass
    java -Xrunprof[:options] ToBeProfiledClass
    javac -J-agentlib:hprof[=options] ToBeProfiledClass
     
     
    第5章 类文件结构
    5.1 字节码
    Native Code:二进制本地机器码。
    Byte Code:字节码。程序编译后的一种与操作系统和机器指令集无关,平台中立的存储格式。为了实现“一次编写,到处运行”。
    Class文件:存储字节码的二进制文件,Class文件包含了Java虚拟机指令集和符号表以及若干其他辅助信息。
    语言无关性:实现语言无关性的基础是虚拟机和字节码存储格式。Java虚拟机不和包括Java在内的任何语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联。各种语言的编译器把源代码编译成Class文件,虚拟机并不关心Class的来源是何种语言。
    Java发展之初,设计者就刻意把Java规范拆分成《Java语言规范》和《Java虚拟机规范》,以便未来更好支持其他语言也运行于JVM之上。
     
    5.2 Class类文件的结构
    任何一个Class文件都对应着唯一的一个类或接口的定义信息,而类或接口并不一定都得定义在文件里(譬如类或接口也可以通过类加载器直接生成)。
    Class文件是二进制文件,各个数据项目严格按照顺序紧凑排列,没有任何分隔符,数据项按大端顺序(最高位字节在地址最低位)存储。由于没有分隔符,Class文件中每个字节的含义、长度、先后顺序等都是严格限定的,不允许被改变。采用类似C语言结构体的伪结构,伪结构中只有两种数据类型:无符号数和表。
    ①无符号数
    属于基本数据类型,以u1、u2、u4、u8来分别代表1个字节到8个字节的无符号数,可以用来描述数字、索引引用、数量值或者UTF8编码字符串。
    ②表
    由多个无符号数或其他表构成的有层次关系的复合数据类型,以“_info”结尾。真个Class文件本质上就是一张表。主要有cp_info(常量池),field_info(字段)、method_info(方法)、attribute_info(属性)。
     
    (1)魔数
    Magic Number:每个Class文件的头4个字节。唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。Class文件的魔数固定为0xCAFEBABE。
    (2)常量池
    第一个表类型,Class文件的资源仓库,是与其他项目关联最多的数据类型。常量池中每一项常量都是一个表,总共有14种常量表类型。主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。
    字面量:比较接近Java语言层面的常量概念,如文本字符串、final常量值等。
    符号引用:属于编译原理概念,包含:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。
     
    5.3 字节码指令
    JVM指令:由操作码(Opcode,一个字节)和跟随其后的零至多个操作数(Operands)构成。大多数指令都只有一个操作码,不含操作数。
    字节码指令集的缺点:由于限制操作码长度为一个字节,JVM操作码总数最大256条;操作数长度不对齐,解释执行会损失一些性能。
    字节码指令集的优点:缺点也正是优点,尽可能获得短小精干的编译代码。
    操作码指令分为如下几大类:
    (1)加载和存储指令
    (2)运算指令
    (3)类型转换指令
    (4)对象创建与访问指令
    (5)操作数栈管理指令
    (6)控制转移指令
    (7)方法调用和返回指令
    (8)异常处理指令
    (9)同步指令
    monitorenter、monitorexit指令。用于java语言的synchronized同步控制。
     
     
    第6章 虚拟机类加载机制
    6.1 概述
    JVM类加载机制:程序运行期间,虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。
    从JSP到OSGI技术,都使用了Java语言运行期类加载的特性。Class文件不一定是磁盘文件,还可能是任何形式存在的一串二进制字节流。
     
    6.2 类加载的时机
    类的整个生命周期包括7个阶段:加载、连接(验证、准备、解析)、初始化、使用、卸载。
    JVM规范没有对第一阶段加载做强制约束,但对初始化阶段严格规定有且只有5种情况必须对类进行“初始化”,分别如下:
    ①遇到new、getstatic、putstatic或invokestatic这4条指令时,如果类没有进行过初始化,则需要先触发其初始化。
    ②使用java.lang.reflect包的方法对类进行反射调用时,如果类没有进行过初始化,则需要先触发其初始化。
    ③当初始化一个类时,如果父类还没有初始化,则需要先触发父类的初始化。但是一个接口在初始化时并不要求其父接口全部都完成初始化,只有真正使用父接口时才会初始化。
    ④当虚拟机启动时,会先初始化那个用户指定的包含main()方法的主类。
    ⑤当使用JDK1.7动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个句柄对应的类没有初始化,则需要先触发其初始化。
     
    除此之外,所有引用类的方式都不会触发初始化,称为被动引用。
     
    6.3 类加载的过程
    (1)加载
    JVM在加载阶段完成3件事:
    ①通过一个类的全限定名来获取定义此类的二进制字节流。
    ②将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
    ③在内存中生成一个代表此类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
     
    (2)验证
    验证是连接阶段的第一步,目的是为了确保Class文件的字节流包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证有4个阶段的校验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。
     
    (3)准备
    准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中分配。这时候内存分配仅包括static类变量,不包括实例变量。
     
    (4)解析
    解析是虚拟机将常量池内的符号引用替换为直接引用的过程。
    符号引用:Symbolic Reference,以一组符号来表示引用的目标,符号可以是任何形式的字面量,符号引用与虚拟机实现的内存布局无关,引用的目标不一定已经加载到内存中。
    直接引用:Direct Reference,是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用与虚拟机实现的内存布局相关,引用目标必定已经在内存中存在。
     
    (5)初始化
    类初始化是类加载过程的最后一步,前面几部完全由虚拟机主导和控制,到了初始化阶段,才真正开始执行类中定义的java程序代码(或者说是字节码)。
     
    6.4 类加载器
    类加载器:虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何获取所需要的类,实现这个动作的代码模块称为“类加载器”。
    对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每个类加载器,都拥有一个独立的类命名空间。
    比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义。这里的相等包括Class对象的equals()方法、isInstance()方法的返回结果,也包括instanceof关键字的对象所属关系判断等。
     
    6.5 双亲委派模型
    类加载器分为4种:启动类加载器、扩展类加载器、应用程序类加载器、自定义类加载器。
    ①启动类加载器:Bootstrap ClassLoader,C++编写,负责加载java_home/lib目录或者-Xbootclasspath参数指定的路径。
    ②扩展类加载器:其以下的类加载器都是Java编写,它负责加载java_home/lib/ext目录或者java.ext.dirs系统变量指定路径。
    ③应用程序类加载器:负责加载用户类路径(classpath)上所指定的类库,若没有自定义类加载器,则其为程序的默认类加载器。
     
    双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父加载器,下层通过组合关系来复用上层父加载器。
    双亲委派工作过程:如果一个类加载器收到了类加载请求,它首先不会自己去尝试加载这个类,而是把请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到这个类)时,子加载器才会尝试自己加载。
    双亲委派好处:Java类随着它的类加载器一起具备了一种带有优先级的层次关系。很好的解决了各个类加载器的基础类的统一问题(越基础的类由越上层的类加载器加载),从而避免混乱,保证程序稳定运作。
     
    破坏双亲委派模型的几个场景:
    ①JNDI服务使用线程上下文类加载器(Thread Context ClassLoader)去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载动作,所有涉及SPI代码的加载动作都采用此方式,如:JNDI、JDBC等。
    ②OSGI的代码热替换、模块热部署等。OSGI实现模块化热部署的关键是它自定义的类加载器的实现。每一个程序模块(OSGI中称为Bundle)都有一个自己的类加载器,当需要更换Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。OSGI环境下,类加载器不再是双亲委派模型中的树状结构,而是更加复杂的网状结构。
     
     
    第7章 虚拟机字节码执行引擎
    7.1 概述
    栈帧:Stack Frame,用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
    编译代码时,一个栈帧需要分配多少内存已经完全确定,不会受运行时变量数据的影响。
    (1)局部变量表
    局部变量表:Local Variable Table,是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。容量以Slot槽为最小单位。编译后容量大小已固定。
    (2)操作数栈
    操作数栈:Operand Stack,栈最大深度编译时已固定,也被写入到字节码中。
    (3)动态连接
    动态连接:Dynamic Linking,常量池中的大量符号引用,一部分在类加载阶段或第一次使用时转化为直接引用,这种转化称为静态解析。而另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
    (4)方法调用
    方法调用不等同于方法执行,方法调用唯一的任务就是确定被调用方法的版本。Class文件的编译过程不包含传统编译中的连接步骤,一切方法调用在Class文件中存储的都是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于直接引用),故方法调用需要在类加载期间,甚至运行期间才能确定目标方法的直接引用。
    方法调用分为解析、静态分配和动态分配。
    解析:Resolution,调用目标在程序写好、编译期进行编译时就必须确定下来,这类方法的调用称为解析。java中符合“编译期可知、运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类。
    静态分配:发生在编译阶段,所有依赖静态类型来定位方法执行版本的分派动作称为静态分配。如方法重载。
    动态分配:发生在运行阶段,和多态的重写(Override)有密切关系。
     
    7.2 基于栈的字节码解释执行引擎
    许多java虚拟机在执行Java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择。
    (1)解释执行
    JDK1.0时代,java语言被称为解释执行语言还算准确。但如今主流虚拟机都包含了即时编译器后,Class文件中的代码被解释执行还是编译执行,就只有虚拟机自己才能准确判断了。
    抽象语法树:Abstract Syntax Tree,AST。编译原理思路是在执行前先对程序源码进行词法分析和语法分析处理,把源码转化为抽象语法树。
    Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。而解释器在虚拟机内部。
    (2)指令集架构
    java的解释执行所使用的指令集是基于栈的指令集架构,当热点代码被JIT即时编译后,会转化得到基于寄存器的指令集架构。指令集架构分为基于栈和基于寄存器两种:
    ①基于栈的指令集架构
    java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分是零地址指令,他们依赖操作数栈进行工作。优点:可移植。缺点:执行速度稍慢。
    ②基于寄存器的指令集架构
    最典型的为x86的二地址指令集,是主流PC机中直接支持的指令集架构。优点:执行速度快。缺点:依赖硬件寄存器,不可移植。
     
     
    第8章 早期(编译期)优化
    8.1 编译器
    编译期:是一段不确定的操作过程,包含了前期java文件到class文件的编译过程,同时也包含运行期的JIT编译或AOT编译。
    这三类编译过程对应有三种编译器:
    ①前端编译器:javac。
    ②JIT编译器:HotSpot VM的C1、C2编译器。
    ③AOT编译器:Ahead Of Time Compiler 也叫静态提前编译器。如:GNU Compiler for the Java(GCJ)。
     
    javac这类前端编译器对代码的运行效率几乎没有任何优化措施,虚拟机设计团队把对性能的优化集中到后端的即时编译器中,这样的好处是让那些不是由javac产生的Class文件(其他语言编译器)也同样能够享受到编译器优化带来的好处。
    很多新生的Java语法特性,都是靠前端编译器的语法糖来实现。如:泛型与类型擦除、自动装箱、拆箱与遍历循环等。
     
    8.2 条件编译
    Java语言实现条件编译的方法是:使用条件为常量的if语句。
     
     
    第9章 晚期(运行期)优化
    9.1 HotSpot虚拟机内的即时编译器
    热点代码:Hot Spot Code,当JVM解释执行时,发现某个方法或代码块的运行特别频繁时,就会将这些代码认定为“热点代码”。
    即时编译器:Just In Time Compiler,JIT编译器。为了提高热点代码的执行效率,在运行时,虚拟机会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为JIT编译器。
     
    9.2 解释器与编译器
    解释器优点:当程序需要迅速启动和执行时,解释器可以首先发挥作用,省去编译的时间,立即执行。
    编译器优点:在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。
    逆优化:Deoptimization,当激进优化的假设不成立,如加载了新类后类型继承结构出现变化,就可以通过逆优化退回到解释状态继续执行。
     
    9.3 C1和C2 即时编译器
    HotSpot虚拟机内置了两个即时编译器,分别称为Client Compiler(C1)和Server Compiler(C2)。目前主流的HotSport默认采用解释器和其中一个编译器直接配合的方式工作。虚拟机会根据自身版本和宿主机器的硬件性能自动选择C1或C2运行模式,用户也可以使用“-client”或“-server”参数强制指定。
     
    虚拟机的三种运行模式:混合模式、解释模式、编译模式。
    混合模式:Mixed Mode,解释器与编译器搭配使用的方式在虚拟机中称为混合模式。
    解释模式:可以用参数“-Xint”强制虚拟机运行于解释模式。
    编译模式:也可以用参数-Xcomp强制虚拟机运行于编译模式(当无法编译时解释器仍然要介入执行过程)。
     
    9.4 分层编译策略
    分层编译策略:为了在程序启动响应速度和运行效率之间达到最佳平衡,根据编译器编译、优化的规模与耗时,划分出不同的编译层次,分别如下:
    ①第0层:程序解释执行。解释器不开启性能监控功能。
    ②第1层:也称为C1编译,将字节码编译为本地代码,进行简单、可靠的优化,如有必要将加入性能监控逻辑。
    ③第2层:也称为C2编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
    C1编译能获取更高的编译速度,而C2编译获取更好的编译质量。
     
    9.5 编译对象与触发条件
    被JIT编译器编译的热点代码分为两类:被多次调用的方法、被多次执行的循环体。
    热点探测判定方法有两种:基于采样点的热点探测、基于计数器的热点探测。HotSpot虚拟机使用基于计数器的热点探测,它为每个方法准备了两类计数器:方法调用计数器和回边计数器。
     
    9.6 编译优化技术
    编译优化技术有近百种,最有代表性的优化技术主要为:
    ①语言无关的经典优化技术:公共子表达式消除、数组范围检查消除。
    ②最重要的优化技术:方法内联。
    ③最前沿的优化技术:逃逸分析。
     
  • 相关阅读:
    C字符串处理函数
    C语言字符串函数大全
    那些闪亮的日子
    牛客网在线编程:幸运数
    牛客网在线编程:水仙花数
    [LeetCode]617.Merge Two Binary Trees
    [LeetCode]657.Judge Route Circle
    [LeetCode]141. Linked List Cycle
    五大算法:分治,贪心,动态规划,回溯,分支界定
    [LeetCode]387.First Unique Character in a String
  • 原文地址:https://www.cnblogs.com/fhwup/p/8587765.html
Copyright © 2011-2022 走看看