Java调优经验谈(精)

对于调优这个事情来说,一般就是三个过程:

  • 性能监控:问题没有发生,你并不知道你需要调优什么?此时需要一些系统、应用的监控工具来发现问题。
  • 性能分析:问题已经发生,但是你并不知道问题到底出在哪里。此时就需要使用工具、经验对系统、应用进行瓶颈分析,以求定位到问题原因。
  • 性能调优:经过上一步的分析定位到了问题所在,需要对问题进行解决,使用代码、配置等手段进行优化。

Java调优也不外乎这三步。

此外,本文所讲的性能分析、调优等是抛开以下因素的:

  • 系统底层环境:硬件、操作系统等
  • 数据结构和算法的使用
  • 外部系统如数据库、缓存的使用

调优准备

调优是需要做好准备工作的,毕竟每一个应用的业务目标都不尽相同,性能瓶颈也不会总在同一个点上。在业务应用层面,我们需要:

  • 需要了解系统的总体架构,明确压力方向。比如系统的哪一个接口、模块是使用率最高的,面临高并发的挑战。
  • 需要构建测试环境来测试应用的性能,使用ab、loadrunner、jmeter都可以。
  • 对关键业务数据量进行分析,这里主要指的是对一些数据的量化分析,如数据库一天的数据量有多少;缓存的数据量有多大等
  • 了解系统的响应速度、吞吐量、TPS、QPS等指标需求,比如秒杀系统对响应速度和QPS的要求是非常高的。
  • 了解系统相关软件的版本、模式和参数等,有时候限于应用依赖服务的版本、模式等,性能也会受到一定的影响。

此外,我们还需要了解Java相关的一些知识:

  1. Java内存相关:这一部分可以参见谈谈Java内存管理一文
  2. 对Java代码进行基准性能测试:可以使用JMH来进行,[译]使用JMH进行微基准测试:不要猜,要测试!
  3. HotSpot VM相关知识:http://www.oracle.com/technetwork/cn/java/javase/tech/index-jsp-136373-zhs.html
  4. jdk自带各种java工具:http://www.rowkey.me/blog/2016/11/03/jdk-tools/

性能分析

在系统层面能够影响应用性能的一般包括三个因素:CPU、内存和IO,可以从这三方面进行程序的性能瓶颈分析。

CPU分析

当程序响应变慢的时候,首先使用top、vmstat、ps等命令查看系统的cpu使用率是否有异常,从而可以判断出是否是cpu繁忙造成的性能问题。其中,主要通过us(用户进程所占的%)这个数据来看异常的进程信息。当us接近100%甚至更高时,可以确定是cpu繁忙造成的响应缓慢。一般说来,cpu繁忙的原因有以下几个:

  • 线程中有无限空循环、无阻塞、正则匹配或者单纯的计算
  • 发生了频繁的gc
  • 多线程的上下文切换

确定好cpu使用率最高的进程之后就可以使用jstack来打印出异常进程的堆栈信息:

jstack [pid]

jstack

接下来需要注意的一点是,Linux下所有线程最终还是以轻量级进程的形式存在系统中的,而使用jstack只能打印出进程的信息,这些信息里面包含了此进程下面所有线程(轻量级进程-LWP)的堆栈信息。因此,进一步的需要确定是哪一个线程耗费了大量cpu,此时可以使用top -p [processId]来查看,也可以直接通过ps -Le来显示所有进程,包括LWP的资源耗费信息。最后,通过在jstack的输出文件中查找对应的lwp的id即可以定位到相应的堆栈信息。其中需要注意的是线程的状态:RUNNABLE、WAITING等。对于Runnable的进程需要注意是否有耗费cpu的计算。对于Waiting的线程一般是锁的等待操作。

也可以使用jstat来查看对应进程的gc信息,以判断是否是gc造成了cpu繁忙。

jstat -gcutil [pid]

jstat

还可以通过vmstat,通过观察内核状态的上下文切换(cs)次数,来判断是否是上下文切换造成的cpu繁忙。

vmstat 1 5

jstat

此外,有时候可能会由jit引起一些cpu飚高的情形,如大量方法编译等。这里可以使用-XX:+PrintCompilation这个参数输出jit编译情况,以排查jit编译引起的cpu问题。

内存分析

对Java应用来说,内存主要是由堆外内存和堆内内存组成。

  1. 堆外内存堆外内存主要是JNI、Deflater/Inflater、DirectByteBuffer(nio中会用到)使用的。对于这种堆外内存的分析,还是需要先通过vmstat、sar、top、pidstat等查看swap和物理内存的消耗状况再做判断的。此外,对于JNI、Deflater这种调用可以通过Google-preftools来追踪资源使用状况。
  2. 堆内内存此部分内存为Java应用主要的内存区域。通常与这部分内存性能相关的有:
    • 创建的对象:这个是存储在堆中的,需要控制好对象的数量和大小,尤其是大的对象很容易进入老年代
    • 全局集合:全局集合通常是生命周期比较长的,因此需要特别注意全局集合的使用
    • 缓存:缓存选用的数据结构不同,会很大程序影响内存的大小和gc
    • ClassLoader:主要是动态加载类容易造成永久代内存不足
    • 多线程:线程分配会占用本地内存,过多的线程也会造成内存不足

    以上使用不当很容易造成:

    • 频繁GC -> Stop the world,使你的应用响应变慢
    • OOM,直接造成内存溢出错误使得程序退出。OOM又可以分为以下几种:
      • Heap space:堆内存不足
      • PermGen space:永久代内存不足
      • Native thread:本地线程没有足够内存可分配

    排查堆内存问题的常用工具是jmap,是jdk自带的。一些常用用法如下:

    • 查看jvm内存使用状况:jmap -heap
    • 查看jvm内存存活的对象:jmap -histo:live
    • 把heap里所有对象都dump下来,无论对象是死是活:jmap -dump:format=b,file=xxx.hprof
    • 先做一次full GC,再dump,只包含仍然存活的对象信息:jmap -dump:format=b,live,file=xxx.hprof

    此外,不管是使用jmap还是在OOM时产生的dump文件,可以使用Eclipse的MAT(MEMORY ANALYZER TOOL)来分析,可以看到具体的堆栈和内存中对象的信息。当然jdk自带的jhat也能够查看dump文件,会启动web端口供开发者使用浏览器浏览堆内对象的信息。

IO分析

通常与应用性能相关的包括:文件IO和网络IO。

  1. 文件IO可以使用系统工具pidstat、iostat、vmstat来查看io的状况。这里可以看一张使用vmstat的结果图。这里主要注意bi和bo这两个值,分别表示块设备每秒接收的块数量和块设备每秒发送的块数量,由此可以判定io繁忙状况。进一步的可以通过使用strace工具定位对文件io的系统调用。通常,造成文件io性能差的原因不外乎:
    • 大量的随机读写
    • 设备慢
    • 文件太大
  2. 网络IO查看网络io状况,一般使用的是netstat工具。可以查看所有连接的状况、数目、端口信息等。例如:当time_wait或者close_wait连接过多时,会影响应用的相应速度。
    1
    netstat -anp

    此外,还可以使用tcpdump来具体分析网络io的数据。当然,tcpdump出的文件直接打开是一堆二进制的数据,可以使用wireshark阅读具体的连接以及其中数据的内容。

    1
    tcpdump -i eth0 -w tmp.cap -tnn dst port 8080 #监听8080端口的网络请求并打印日志到tmp.cap中

    还可以通过查看/proc/interrupts来获取当前系统使用的中断的情况。

    各个列依次是:

    1
    irq的序号, 在各自cpu上发生中断的次数,可编程中断控制器,设备名称(request_irq的dev_name字段)

    通过查看网卡设备的终端情况可以判断网络io的状况。

其他分析工具

上面分别针对CPU、内存以及IO讲了一些系统/JDK自带的分析工具。除此之外,还有一些综合分析工具或者框架可以更加方便我们对Java应用性能的排查、分析、定位等。

  • VisualVM这个工具应该是Java开发者们非常熟悉的一款java应用监测工具,原理是通过jmx接口来连接jvm进程,从而能够看到jvm上的线程、内存、类等信息。


如果想进一步查看gc情况,可以安装visual gc插件。此外,visualvm也有btrace的插件,可以可视化直观的编写btrace代码并查看输出日志。 与VisualVm类似的,jconsole也是通过jmx查看远程jvm信息的一款工具,更进一步的,通过它还可以显示具体的线程堆栈信息以及内存中各个年代的占用情况,也支持直接远程执行MBEAN。当然,visualvm通过安装jconsole插件也可以拥有这些功能。

但由于这俩工具都是需要ui界面的,因此一般都是通过本地远程连接服务器jvm进程。服务器环境下,一般并不用此种方式。

  • Java Mission Control(jmc)此工具是jdk7 u40开始自带的,原来是JRockit上的工具,是一款采样型的集诊断、分析和监控与一体的非常强大的工具。https://docs.oracle.com/javacomponents/jmc-5-5/jmc-user-guide/toc.htm
  • Btrace这里不得不提的是btrace这个神器,它使用java attach api+ java agent + instrument api能够实现jvm的动态追踪。在不重启应用的情况下可以加入拦截类的方法以打印日志等。具体的用法可以参考Btrace入门到熟练小工完全指南
  • JwebapJwebap是一款JavaEE性能检测框架,基于asm增强字节码实现。支持:http请求、jdbc连接、method的调用轨迹跟踪以及次数、耗时的统计。由此可以获取最耗时的请求、方法,并可以查看jdbc连接的次数、是否关闭等。但此项目是2006年的一个项目,已经将近10年没有更新。根据笔者使用,已经不支持jdk7编译的应用。如果要使用,建议基于原项目二次开发,同时也可以加入对redis连接的轨迹跟踪。当然,基于字节码增强的原理,也可以实现自己的JavaEE性能监测框架。上图来自笔者公司二次开发过的jwebap,已经支持jdk8和redis连接追踪。
  • useful-scripts这里有一个本人参与的开源的项目:https://github.com/superhj1987/useful-scripts,封装了很多常用的性能分析命令,比如上文讲的打印繁忙java线程堆栈信息,只需要执行一个脚本即可。

性能调优

与性能分析相对应,性能调优同样分为三部分。

CPU调优

  • 不要存在一直运行的线程(无限while循环),可以使用sleep休眠一段时间。这种情况普遍存在于一些pull方式消费数据的场景下,当一次pull没有拿到数据的时候建议sleep一下,再做下一次pull。
  • 轮询的时候可以使用wait/notify机制
  • 避免循环、正则表达式匹配、计算过多,包括使用String的format、split、replace方法(可以使用apache的commons-lang里的StringUtils对应的方法),使用正则去判断邮箱格式(有时候会造成死循环)、序列/反序列化等。
  • 结合jvm和代码,避免产生频繁的gc,尤其是full GC。

此外,使用多线程的时候,还需要注意以下几点:

  • 使用线程池,减少线程数以及线程的切换
  • 多线程对于锁的竞争可以考虑减小锁的粒度(使用ReetrantLock)、拆分锁(类似ConcurrentHashMap分bucket上锁), 或者使用CAS、ThreadLocal、不可变对象等无锁技术。此外,多线程代码的编写最好使用jdk提供的并发包、Executors框架以及ForkJoin等,此外DiscuptorActor在合适的场景也可以使用。

内存调优

内存的调优主要就是对jvm的调优。

  • 合理设置各个代的大小。避免新生代设置过小(不够用,经常minor gc并进入老年代)以及过大(会产生碎片),同样也要避免Survivor设置过大和过小。
  • 选择合适的GC策略。需要根据不同的场景选择合适的gc策略。这里需要说的是,cms并非全能的。除非特别需要再设置,毕竟cms的新生代回收策略parnew并非最快的,且cms会产生碎片。此外,G1直到jdk8的出现也并没有得到广泛应用,并不建议使用。
  • jvm启动参数配置-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:[log_path],以记录gc日志,便于排查问题。

其中,对于第一点,具体的还有一点建议:

  • 年轻代大小选择:响应时间优先的应用,尽可能设大,直到接近系统的最低响应时间限制(根据实际情况选择)。在此种情况下,年轻代收集发生gc的频率是最小的。同时,也能够减少到达年老代的对象。吞吐量优先的应用,也尽可能的设置大,因为对响应时间没有要求,垃圾收集可以并行进行,建议适合8CPU以上的应用使用。
  • 年老代大小选择:响应时间优先的应用,年老代一般都是使用并发收集器,所以其大小需要小心设置,一般要考虑并发会话率和会话持续时间等一些参数。如果堆设置小了,会造成内存碎片、高回收频率以及应用暂停而使用传统的标记清除方式;如果堆大了,则需要较长的收集时间。最优化的方案,一般需要参考以下数据获得:
    • 并发垃圾收集信息
    • 持久代并发收集次数
    • 传统GC信息
    • 花在年轻代和年老代回收上的时间比例

    一般吞吐量优先的应用都应该有一个很大的年轻代和一个较小的年老代。这样可以尽可能回收掉大部分短期对象,减少中期的对象,而年老代存放长期存活对象。

此外,较小堆引起的碎片问题:因为年老代的并发收集器使用标记、清除算法,所以不会对堆进行压缩。当收集器回收时,会把相邻的空间进行合并,这样可以分配给较大的对象。但是,当堆空间较小时,运行一段时间以后,就会出现“碎片”,如果并发收集器找不到足够的空间,那么并发收集器将会停止,然后使用传统的标记、清除方式进行回收。如果出现“碎片”,可能需要进行如下配置:-XX:+UseCMSCompactAtFullCollection,使用并发收集器时,开启对年老代的压缩。同时使用-XX:CMSFullGCsBeforeCompaction=xx设置多少次Full GC后,对年老代进行压缩。

其余对于jvm的优化问题可见后面JVM参数进阶一节。

代码上,也需要注意:

  • 避免保存重复的String对象,同时也需要小心String.subString()与String.intern()的使用
  • 尽量不要使用finalizer
  • 释放不必要的引用:ThreadLocal使用完记得释放以防止内存泄漏,各种stream使用完也记得close。
  • 使用对象池避免无节制创建对象,造成频繁gc。但不要随便使用对象池,除非像连接池、线程池这种初始化/创建资源消耗较大的场景,
  • 缓存失效算法,可以考虑使用SoftReference、WeakReference保存缓存对象
  • 谨慎热部署/加载的使用,尤其是动态加载类等
  • 不要用Log4j输出文件名、行号,因为Log4j通过打印线程堆栈实现,生成大量String。此外,使用log4j时,建议此种经典用法,先判断对应级别的日志是否打开,再做操作,否则也会生成大量String。
    1
    2
    3
    if (logger.isInfoEnabled()) {
          logger.info(msg);
      }

IO调优

文件IO上需要注意:

  • 考虑使用异步写入代替同步写入,可以借鉴redis的aof机制。
  • 利用缓存,减少随机读
  • 尽量批量写入,减少io次数和寻址
  • 使用数据库代替文件存储

网络IO上需要注意:

  • 和文件IO类似,使用异步IO、多路复用IO/事件驱动IO代替同步阻塞IO
  • 批量进行网络IO,减少IO次数
  • 使用缓存,减少对网络数据的读取
  • 使用协程: Quasar

其他优化建议

  • 算法、逻辑上是程序性能的首要,遇到性能问题,应该首先优化程序的逻辑处理
  • 优先考虑使用返回值而不是异常表示错误
  • 查看自己的代码是否对内联是友好的: 你的Java代码对JIT编译友好么?

此外,jdk7、8在jvm的性能上做了一些增强:

  • 通过-XX:+TieredCompilation开启JDK7的多层编译(tiered compilation)支持。多层编译结合了客户端C1编译器和服务端C2编译器的优点(客户端编译能够快速启动和及时优化,服务器端编译可以提供更多的高级优化),是一个非常高效利用资源的切面方案。在开始时先进行低层次的编译,同时收集信息,在后期再进一步进行高层次的编译进行高级优化。需要注意的一点:这个参数会消耗比较多的内存资源,因为同一个方法被编译了多次,存在多份native内存拷贝,建议把code cache调大一点儿(-XX:+ReservedCodeCacheSize,InitialCodeCacheSize)。否则有可能由于code cache不足,jit编译的时候不停的尝试清理code cache,丢弃无用方法,消耗大量资源在jit线程上。
  • Compressed Oops:压缩指针在jdk7中的server模式下已经默认开启。
  • Zero-Based Compressed Ordinary Object Pointers:当使用了上述的压缩指针时,在64位jvm上,会要求操作系统保留从一个虚拟地址0开始的内存。如果操作系统支持这种请求,那么就开启了Zero-Based Compressed Oops。这样可以使得无须在java堆的基地址添加任何地址补充即可把一个32位对象的偏移解码成64位指针。
  • 逃逸分析(Escape Analysis): Server模式的编译器会根据代码的情况,来判断相关对象的逃逸类型,从而决定是否在堆中分配空间,是否进行标量替换(在栈上分配原子类型局部变量)。此外,也可以根据调用情况来决定是否自动消除同步控制,如StringBuffer。这个特性从Java SE 6u23开始就默认开启。
  • NUMA Collector Enhancements:这个重要针对的是The Parallel Scavenger垃圾回收器。使其能够利用NUMA (Non Uniform Memory Access,即每一个处理器核心都有本地内存,能够低延迟、高带宽访问) 架构的机器的优势来更快的进行gc。可以通过-XX:+UseNUMA开启支持。

此外,网上还有很多过时的建议,不要再盲目跟随:

  • 变量用完设置为null,加快内存回收,这种用法大部分情况下并没有意义。一种情况除外:如果有个Java方法没有被JIT编译但里面仍然有代码会执行比较长时间,那么在那段会执行长时间的代码前显式将不需要的引用类型局部变量置null是可取的。具体的可以见R大的解释:https://www.zhihu.com/question/48059457/answer/113538171
  • 方法参数设置为final,这种用法也没有太大的意义,尤其在jdk8中引入了effective final,会自动识别final变量。

JVM参数进阶

jvm的参数设置一直是比较理不清的地方,很多时候都搞不清都有哪些参数可以配置,参数是什么意思,为什么要这么配置等。这里主要针对这些做一些常识性的说明以及对一些容易让人进入陷阱的参数做一些解释。

以下所有都是针对Oracle/Sun JDK 6来讲

  1. 启动参数默认值Java有很多的启动参数,而且很多版本都并不一样。但是现在网上充斥着各种资料,如果不加辨别的全部使用,很多是没有效果或者本来就是默认值的。一般的,我们可以通过使用java -XX:+PrintFlagsInitial来查看所有可以设置的参数以及其默认值。也可以在程序启动的时候加入-XX:+PrintCommandLineFlags来查看与默认值不相同的启动参数。如果想查看所有启动参数(包括和默认值相同的),可以使用-XX:+PrintFlagsFinal。输出里“=”表示使用的是初始默认值,而“:=”表示使用的不是初始默认值,可能是命令行传进来的参数、配置文件里的参数或者是ergonomics自动选择了别的值。此外,还可以使用jinfo命令显示启动的参数。
    • jinfo -flags [pid] #查看目前启动使用的有效参数
    • jinfo -flag [flagName] [pid] #查看对应参数的值

    这里需要指出的是,当你配置jvm参数时,最好是先通过以上命令查看对应参数的默认值再确定是否需要设置。也最好不要配置你搞不清用途的参数,毕竟默认值的设置是有它的合理之处的。

  1. 动态设置参数当Java应用启动后,定位到了是GC造成的性能问题,但是你启动的时候并没有加入打印gc的参数,很多时候的做法就是重新加参数然后重启应用。但这样会造成一定时间的服务不可用。最佳的做法是能够在不重启应用的情况下,动态设置参数。使用jinfo可以做到这一点(本质上还是基于jmx的)。
    1
    2
    jinfo -flag [+/-][flagName] [pid] #启用/禁止某个参数
    jinfo -flag [flagName=value] [pid] #设置某个参数

    对于上述的gc的情况,就可以使用以下命令打开heap dump并设置dump路径。

    1
    2
    3
    jinfo -flag +HeapDumpBeforeFullGC [pid]
    jinfo -flag +HeapDumpAfterFullGC [pid]
    jinfo -flag HeapDumpPath=/home/dump/dir [pid]

    同样的也可以动态关闭。

    1
    2
    jinfo -flag -HeapDumpBeforeFullGC [pid]
    jinfo -flag -HeapDumpAfterFullGC [pid]

    其他的参数设置类似。

  2. -verbose:gc 与 -XX:+PrintGCDetails很多gc推荐设置都同时设置了这两个参数,其实,只要打开了-XX:+PrintGCDetails,前面的选项也会同时打开,无须重复设置。
  3. -XX:+DisableExplicitGC这个参数的作用就是使得system.gc变为空调用,很多推荐设置里面都是建议开启的。但是,如果你用到了NIO或者其他使用到堆外内存的情况,使用此选项会造成oom。可以用XX:+ExplicitGCInvokesConcurrent或XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses(配合CMS使用,使得system.gc触发一次并发gc)代替。此外,还有一个比较有意思的地方。如果你不设置此选项的话,当你使用了RMI的时候,会周期性地来一次full gc。这个现象是由于分布式gc造成的,为RMI服务。具体的可见此链接内容中与dgc相关的:http://docs.oracle.com/javase/6/docs/technotes/guides/rmi/sunrmiproperties.html
  4. MaxDirectMemorySize此参数是设置的堆外内存的上限值。当不设置的时候为-1,此值为-Xmx减去一个survivor space的预留大小。
  5. 由于遗留原因,作用相同的参数
    • -Xss 与 -XX:ThreadStackSize
    • -Xmn 与 -XX:NewSize,此外这里需要注意的是设置了-Xmn的话,NewRatio就没作用了。
  6. -XX:MaxTenuringThreshold使用工具查看此值默认值为15,但是选择了CMS的时候,此值会变成4。当此值设置为0时,所有eden里的活对象在经历第一次minor GC的时候就会直接晋升到old gen,survivor space直接就没用。
  7. -XX:HeapDumpPath使用此参数可以指定-XX:+HeapDumpBeforeFullGC、-XX:+HeapDumpAfterFullGC、-XX:+HeapDumpOnOutOfMemoryError触发heap dump文件的存储位置。

参考资料

转自:http://www.importnew.com/22336.html

JVM工作原理

一、         JVM的生命周期

1.       JVM实例对应了一个独立运行的java程序它是进程级别

a)     启动。启动一个Java程序时,一个JVM实例就产生了,任何一个拥有public static void main(String[] args)函数的class都可以作为JVM实例运行的起点

b)     运行。main()作为该程序初始线程的起点,任何其他线程均由该线程启动。JVM内部有两种线程:守护线程和非守护线程,main()属于非守护线程,守护线程通常由JVM自己使用,java程序也可以标明自己创建的线程是守护线程

c)     消亡。当程序中的所有非守护线程都终止时,JVM才退出;若安全管理器允许,程序也可以使用Runtime类或者System.exit()来退出

2.       JVM执行引擎实例则对应了属于用户运行程序的线程它是线程级别的

 

二、         JVM的体系结构

 

1.       类装载器(ClassLoader)(用来装载.class文件)

2.       执行引擎(执行字节码,或者执行本地方法)

3.       运行时数据区(方法区、堆、java栈、PC寄存器、本地方法栈)

 

三、         JVM类加载器

JVM整个类加载过程的步骤:

1.       装载

装载过程负责找到二进制字节码并加载至JVM中,JVM通过类名、类所在的包名通过ClassLoader来完成类的加载,同样,也采用以上三个元素来标识一个被加载了的类:类名+

包名+ClassLoader实例ID。

2.       链接

链接过程负责对二进制字节码的格式进行校验、初始化装载类中的静态变量以及解析类中调用的接口、类。

完成校验后,JVM初始化类中的静态变量,并将其值赋为默认值。

最后对类中的所有属性、方法进行验证,以确保其需要调用的属性、方法存在,以及具备应的权限(例如public、private域权限等),会造成NoSuchMethodError、NoSuchFieldError等错误信息。

3.       初始化

初始化过程即为执行类中的静态初始化代码、构造器代码以及静态属性的初始化,在四种情况下初始化过程会被触发执行:

调用了new;

反射调用了类中的方法;

子类调用了初始化;

JVM启动过程中指定的初始化类。

 

JVM类加载顺序:

JVM两种类装载器包括:启动类装载器和用户自定义类装载器。

启动类装载器是JVM实现的一部分;

用户自定义类装载器则是Java程序的一部分,必须是ClassLoader类的子类。

JVM装载顺序:

Jvm启动时,由Bootstrap向User-Defined方向加载类;

应用进行ClassLoader时,由User-Defined向Bootstrap方向查找并加载类;

1.       Bootstrap ClassLoader

这是JVM的根ClassLoader,它是用C++实现的,JVM启动时初始化此ClassLoader,并由此ClassLoader完成$JAVA_HOME中jre/lib/rt.jar(Sun JDK的实现)中所有class文件的加载,这个jar中包含了java规范定义的所有接口以及实现。

2.       Extension ClassLoader

JVM用此classloader来加载扩展功能的一些jar包。

3.       System ClassLoader

JVM用此classloader来加载启动参数中指定的Classpath中的jar包以及目录,在Sun JDK中ClassLoader对应的类名为AppClassLoader。

4.       User-Defined ClassLoader

User-DefinedClassLoader是Java开发人员继承ClassLoader抽象类自行实现的ClassLoader,基于自定义的ClassLoader可用于加载非Classpath中的jar以及目录。

 

ClassLoader抽象类的几个关键方法:

(1)       loadClass

此方法负责加载指定名字的类,ClassLoader的实现方法为先从已经加载的类中寻找,如没有则继续从parent ClassLoader中寻找,如仍然没找到,则从System ClassLoader中寻找,最后再调用findClass方法来寻找,如要改变类的加载顺序,则可覆盖此方法

(2)       findLoadedClass

此方法负责从当前ClassLoader实例对象的缓存中寻找已加载的类,调用的为native的方法。

(3)       findClass

此方法直接抛出ClassNotFoundException,因此需要通过覆盖loadClass或此方法来以自定义的方式加载相应的类。

(4)       findSystemClass

此方法负责从System ClassLoader中寻找类,如未找到,则继续从Bootstrap ClassLoader中寻找,如仍然为找到,则返回null。

(5)       defineClass

此方法负责将二进制的字节码转换为Class对象

(6)       resolveClass

此方法负责完成Class对象的链接,如已链接过,则会直接返回。

 

四、         JVM执行引擎

在执行方法时JVM提供了四种指令来执行:

(1)invokestatic:调用类的static方法

(2)invokevirtual:调用对象实例的方法

(3)invokeinterface:将属性定义为接口来进行调用

(4)invokespecial:JVM对于初始化对象(Java构造器的方法为:<init>)以及调用对象实例中的私有方法时。

 

主要的执行技术有:

解释,即时编译,自适应优化、芯片级直接执行

(1)解释属于第一代JVM,

(2)即时编译JIT属于第二代JVM,

(3)自适应优化(目前Sun的HotspotJVM采用这种技术)则吸取第一代JVM和第二代

JVM的经验,采用两者结合的方式

开始对所有的代码都采取解释执行的方式,并监视代码执行情况,然后对那些经常调用的方法启动一个后台线程,将其编译为本地代码,并进行优化。若方法不再频繁使用,则取消编译过的代码,仍对其进行解释执行。

 

五、         JVM运行时数据区

第一块:PC寄存器

PC寄存器是用于存储每个线程下一步将执行的JVM指令,如该方法为native的,则PC寄存器中不存储任何信息。

第二块:JVM栈

JVM栈是线程私有的,每个线程创建的同时都会创建JVM栈,JVM栈中存放的为当前线程中局部基本类型的变量(java中定义的八种基本类型:boolean、char、byte、short、int、long、float、double)、部分的返回结果以及Stack Frame,非基本类型的对象在JVM栈上仅存放一个指向堆上的地址

第三块:堆(Heap)

它是JVM用来存储对象实例以及数组值的区域,可以认为Java中所有通过new创建的对象的内存都在此分配,Heap中的对象的内存需要等待GC进行回收。

(1)       堆是JVM中所有线程共享的,因此在其上进行对象内存的分配均需要进行加锁,这也导致了new对象的开销是比较大的

(2)       Sun Hotspot JVM为了提升对象内存分配的效率,对于所创建的线程都会分配一块独立的空间TLAB(Thread Local Allocation Buffer),其大小由JVM根据运行的情况计算而得,在TLAB上分配对象时不需要加锁,因此JVM在给线程的对象分配内存时会尽量的在TLAB上分配,在这种情况下JVM中分配对象内存的性能和C基本是一样高效的,但如果对象过大的话则仍然是直接使用堆空间分配

(3)       TLAB仅作用于新生代的Eden Space,因此在编写Java程序时,通常多个小的对象比大的对象分配起来更加高效。

第四块:方法区域(Method Area)

(1)在Sun JDK中这块区域对应的为PermanetGeneration,又称为持久代。

(2)方法区域存放了所加载的类的信息(名称、修饰符等)、类中的静态变量、类中定义为final类型的常量、类中的Field信息、类中的方法信息,当开发人员在程序中通过Class

对象中的getName、isInterface等方法来获取信息时,这些数据都来源于方法区域,同时方法区域也是全局共享的,在一定的条件下它也会被GC,当方法区域需要使用的内存超过其允许的大小时,会抛出OutOfMemory的错误信息。

第五块:运行时常量池(Runtime Constant Pool)

存放的为类中的固定的常量信息、方法和Field的引用信息等,其空间从方法区域中分配。

第六块:本地方法堆栈(Native Method Stacks)

JVM采用本地方法堆栈来支持native方法的执行,此区域用于存储每个native方法调用的状态。

 

六、         JVM垃圾回收

GC的基本原理:将内存中不再被使用的对象进行回收,GC中用于回收的方法称为收集器,由于GC需要消耗一些资源和时间,Java在对对象的生命周期特征进行分析后,按照新生代、旧生代的方式来对对象进行收集,以尽可能的缩短GC对应用造成的暂停

(1)对新生代的对象的收集称为minor GC;

(2)对旧生代的对象的收集称为Full GC;

(3)程序中主动调用System.gc()强制执行的GC为Full GC。

不同的对象引用类型, GC会采用不同的方法进行回收,JVM对象的引用分为了四种类型:

(1)强引用:默认情况下,对象采用的均为强引用(这个对象的实例没有其他对象引用,GC时才会被回收)

(2)软引用:软引用是Java中提供的一种比较适合于缓存场景的应用(只有在内存不够用的情况下才会被GC)

(3)弱引用:在GC时一定会被GC回收

(4)虚引用:由于虚引用只是用来得知对象是否被GC

Java常见内存溢出异常分析

Java虚拟机规范规定JVM的内存分为了好几块,比如堆,栈,程序计数器,方法区等,而Hotspot jvm的实现中,将堆内存分为了三部分,新生代,老年代,持久带,其中持久带实现了规范中规定的方法区,而内存模型中不同的部分都会出现相应的OOM错误,接下来我们就分开来讨论一下。

栈溢出(StackOverflowError)

栈溢出抛出java.lang.StackOverflowError错误,出现此种情况是因为方法运行的时候栈的深度超过了虚拟机容许的最大深度所致。

出现这种情况,一般情况下是程序错误所致的,比如写了一个死递归,就有可能造成此种情况。 下面我们通过一段代码来模拟一下此种情况的内存溢出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.util.*;
import java.lang.*;
public class OOMTest{
  public void stackOverFlowMethod(){
      stackOverFlowMethod();
  }
  public static void main(String... args){
      OOMTest oom = new OOMTest();
      oom.stackOverFlowMethod();
  }
}

运行上面的代码,会抛出如下的异常:

1
2
Exception in thread "main" java.lang.StackOverflowError
        at OOMTest.stackOverFlowMethod(OOMTest.java:6)

堆溢出(OutOfMemoryError:java heap space)

堆内存溢出的时候,虚拟机会抛出java.lang.OutOfMemoryError:java heap space,出现此种情况的时候,我们需要根据内存溢出的时候产生的dump文件来具体分析(需要增加-XX:+HeapDumpOnOutOfMemoryErrorjvm启动参数)。出现此种问题的时候有可能是内存泄露,也有可能是内存溢出了。
如果内存泄露,我们要找出泄露的对象是怎么被GC ROOT引用起来,然后通过引用链来具体分析泄露的原因。
如果出现了内存溢出问题,这往往是程序本生需要的内存大于了我们给虚拟机配置的内存,这种情况下,我们可以采用调大-Xmx来解决这种问题。

下面我们通过如下的代码来演示一下此种情况的溢出:

1
2
3
4
5
6
7
8
9
10
import java.util.*;
import java.lang.*;
public class OOMTest{
        public static void main(String... args){
                List<byte[]> buffer = new ArrayList<byte[]>();
                buffer.add(new byte[10*1024*1024]);
        }
}

我们通过如下的命令运行上面的代码:

java -verbose:gc -Xmn10M -Xms20M -Xmx20M -XX:+PrintGC OOMTest

程序输入如下的信息:

1
2
3
4
5
[GC 1180K->366K(19456K), 0.0037311 secs]
[Full GC 366K->330K(19456K), 0.0098740 secs]
[Full GC 330K->292K(19456K), 0.0090244 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
        at OOMTest.main(OOMTest.java:7)

从运行结果可以看出,JVM进行了一次Minor gc和两次的Major gc,从Major gc的输出可以看出,gc以后old区使用率为134K,而字节数组为10M,加起来大于了old generation的空间,所以抛出了异常,如果调整-Xms21M,-Xmx21M,那么就不会触发gc操作也不会出现异常了。

通过上面的实验其实也从侧面验证了一个结论:当对象大于新生代剩余内存的时候,将直接放入老年代,当老年代剩余内存还是无法放下的时候,出发垃圾收集,收集后还是不能放下就会抛出内存溢出异常了

持久带溢出(OutOfMemoryError: PermGen space)

我们知道Hotspot jvm通过持久带实现了Java虚拟机规范中的方法区,而运行时的常量池就是保存在方法区中的,因此持久带溢出有可能是运行时常量池溢出,也有可能是方法区中保存的class对象没有被及时回收掉或者class信息占用的内存超过了我们配置。当持久带溢出的时候抛出java.lang.OutOfMemoryError: PermGen space
我在工作可能在如下几种场景下出现此问题。

  1. 使用一些应用服务器的热部署的时候,我们就会遇到热部署几次以后发现内存溢出了,这种情况就是因为每次热部署的后,原来的class没有被卸载掉。
  2. 如果应用程序本身比较大,涉及的类库比较多,但是我们分配给持久带的内存(通过-XX:PermSize和-XX:MaxPermSize来设置)比较小的时候也可能出现此种问题。
  3. 一些第三方框架,比如spring,hibernate都通过字节码生成技术(比如CGLib)来实现一些增强的功能,这种情况可能需要更大的方法区来存储动态生成的Class文件。

我们知道Java中字符串常量是放在常量池中的,String.intern()这个方法运行的时候,会检查常量池中是否存和本字符串相等的对象,如果存在直接返回对常量池中对象的引用,不存在的话,先把此字符串加入常量池,然后再返回字符串的引用。那么我们就可以通过String.intern方法来模拟一下运行时常量区的溢出.下面我们通过如下的代码来模拟此种情况:

1
2
3
4
5
6
7
8
9
10
11
12
import java.util.*;
import java.lang.*;
public class OOMTest{
        public static void main(String... args){
                List<String> list = new ArrayList<String>();
                while(true){
                        list.add(UUID.randomUUID().toString().intern());
                }
        }
}

我们通过如下的命令运行上面代码:

java -verbose:gc -Xmn5M -Xms10M -Xmx10M -XX:MaxPermSize=1M -XX:+PrintGC OOMTest

运行后的输入如下图所示:

1
2
3
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
        at java.lang.String.intern(Native Method)
        at OOMTest.main(OOMTest.java:8)

通过上面的代码,我们成功模拟了运行时常量池溢出的情况,从输出中的PermGen space可以看出确实是持久带发生了溢出,这也验证了,我们前面说的Hotspot jvm通过持久带来实现方法区的说法。

OutOfMemoryError:unable to create native thread

最后我们在来看看java.lang.OutOfMemoryError:unable to create natvie thread这种错误。 出现这种情况的时候,一般是下面两种情况导致的:

  1. 程序创建的线程数超过了操作系统的限制。对于Linux系统,我们可以通过ulimit -u来查看此限制。
  2. 给虚拟机分配的内存过大,导致创建线程的时候需要的native内存太少。我们都知道操作系统对每个进程的内存是有限制的,我们启动Jvm,相当于启动了一个进程,假如我们一个进程占用了4G的内存,那么通过下面的公式计算出来的剩余内存就是建立线程栈的时候可以用的内存。 线程栈总可用内存=4G-(-Xmx的值)- (-XX:MaxPermSize的值)- 程序计数器占用的内存 通过上面的公式我们可以看出,-Xmx 和 MaxPermSize的值越大,那么留给线程栈可用的空间就越小,在-Xss参数配置的栈容量不变的情况下,可以创建的线程数也就越小。因此如果是因为这种情况导致的unable to create native thread,那么要么我们增大进程所占用的总内存,或者减少-Xmx或者-Xss来达到创建更多线程的目的。

JVM监控与调优

光说不练假把式,学习Java GC机制的目的是为了实用,也就是为了在JVM出现问题时分析原因并解决之。通过学习,我觉得JVM监控与调优主要的着眼点在于如何配置、如何监控、如何优化3点上。下面就将针对这3点进行学习。
(如果您对Java的内存区域划分和内存回收机制尚不明确,那在阅读本文前,请先阅读我的前一篇博客《Java系列笔记(3) – Java 内存区域和GC机制》,在该博客中,详细叙述了Java HotSpot虚拟机(Sun/Oracle JDK系列默认的虚拟机)的内存分配和垃圾回收机制。本文很多内容将依据上一篇博客,同时,本文所针对的虚拟机,也是HotSpot虚拟机。)
参数设置

在Java虚拟机的参数中,有3种表示方法(出自:http://www.cnblogs.com/wenfeng762/archive/2011/08/14/2137810.html),用“ps -ef |grep “java”命令,可以得到当前Java进程的所有启动参数和配置参数:

  • 标准参数(-),所有的JVM实现都必须实现这些参数的功能,而且向后兼容;
  • 非标准参数(-X),默认jvm实现这些参数的功能,但是并不保证所有jvm实现都满足,且不保证向后兼容;
  • 非Stable参数(-XX),此类参数各个jvm实现会有所不同,将来可能会随时取消,需要慎重使用(但是,这些参数往往是非常有用的);

(额外的,-DpropertyName=“value”的形式定义了一些全局属性值,下面有介绍。)
本文只重点介绍一些重要和常用的参数,如果想了解全部参数,可以参考下面的文章:

标准参数

其实标准参数是用过Java的人都最熟悉的,就是你在运行java命令时后面加上的参数,如java -version, java -jar 等,输入命令java -help或java -?就能获得当前机器所有java的标准参数列表。
-client
设置jvm使用client模式,这是一般在pc机器上使用的模式,启动很快,但性能和内存管理效率并不高;多用于桌面应用;

-server
使用server模式,启动速度虽然慢(比client模式慢10%左右),但是性能和内存管理效率很高,适用于服务器,用于生成环境、开发环境或测试环境的服务端;
如果没有指定-server或-client,JVM启动的时候会自动检测当前主机是否为服务器,如果是就以server模式启动,64位的JVM只有server模式,所以无法使用-client参数;
默认情况下,不同的启动模式,执行GC的方式有所区别:

启动模式 新生代GC方式 旧生代和持久代GC的方式
client 串行 串行
server 并行 并发

如果没有指定-server或-client模式,则判断方法如下:

-classpath / -cp
JVM加载和搜索文件的目录路径,多个路径用;分隔。注意,如果使用了-classpath,JVM就不会再搜索环境变量中定义的CLASSPATH路径。
JVM搜索路径的顺序为:
1,先搜索JVM自带的jar或zip包(Bootstrat,搜索路径可以用System.getProperty(“sun.boot.class.path”)获得);
2,搜索JRE_HOME/lib/ext下的jar包(Extension,搜索路径可以用System.getProperty(“java.ext.dirs”)获得);
3,搜索用户自定义目录,顺序为:当前目录(.),CLASSPATH,-cp;(搜索路径用System.getProperty(“java.class.path”)获得)

-DpropertyName=value
定义系统的全局属性值,如配置文件地址等,如果value有空格,可以用-Dname=”space string”这样的形式来定义,用System.getProperty(“propertyName”)可以获得这些定义的属性值,在代码中也可以用System.setProperty(“propertyName”,”value”)的形式来定义属性。

-verbose
这是查询GC问题最常用的命令之一,具体参数如:
-verbose:class
输出jvm载入类的相关信息,当jvm报告说找不到类或者类冲突时可此进行诊断。
-verbose:gc
输出每次GC的相关情况,后面会有更详细的介绍。
-verbose:jni
输出native方法调用的相关情况,一般用于诊断jni调用错误信息。

非标准参数
非标准参数,是在标准参数的基础上进行扩展的参数,输入“java -X”命令,能够获得当前JVM支持的所有非标准参数列表(你会发现,其实并不多哦)。

在不同类型的JVM中,采用的参数有所不同,
在讲解非标准参数时,请参考下面的图,对内存区域的大小有个形象的了解(下图出自:http://iamzhongyong.iteye.com/blog/1333100):


-Xmn
新生代内存大小的最大值,包括E区和两个S区的总和,使用方法如:-Xmn65535,-Xmn1024k,-Xmn512m,-Xmn1g (-Xms,-Xmx也是种写法)
-Xmn只能使用在JDK1.4或之后的版本中,(之前的1.3/1.4版本中,可使用-XX:NewSize设置年轻代大小,用-XX:MaxNewSize设置年轻代最大值);
如果同时设置了-Xmn和-XX:NewSize,-XX:MaxNewSize,则谁设置在后面,谁就生效;如果同时设置了-XX:NewSize -XX:MaxNewSize与-XX:NewRatio则实际生效的值是:min(MaxNewSize,max(NewSize, heap/(NewRatio+1)))(看考:http://www.open-open.com/home/space.php?uid=71669&do=blog&id=8891)
在开发、测试环境,可以-XX:NewSize 和 -XX:MaxNewSize来设置新生代大小,但在线上生产环境,使用-Xmn一个即可(推荐),或者将-XX:NewSize 和 -XX:MaxNewSize设置为同一个值,这样能够防止在每次GC之后都要调整堆的大小(即:抖动,抖动会严重影响性能)

-Xms
初始堆的大小,也是堆大小的最小值,默认值是总共的物理内存/64(且小于1G),默认情况下,当堆中可用内存小于40%(这个值可以用-XX: MinHeapFreeRatio 调整,如-X:MinHeapFreeRatio=30)时,堆内存会开始增加,一直增加到-Xmx的大小;

-Xmx
堆的最大值,默认值是总共的物理内存/64(且小于1G),如果Xms和Xmx都不设置,则两者大小会相同,默认情况下,当堆中可用内存大于70%(这个值可以用-XX: MaxHeapFreeRatio 调整,如-X:MaxHeapFreeRatio=60)时,堆内存会开始减少,一直减小到-Xms的大小;
整个堆的大小=年轻代大小+年老代大小,堆的大小不包含持久代大小,如果增大了年轻代,年老代相应就会减小,官方默认的配置为年老代大小/年轻代大小=2/1左右(使用-XX:NewRatio可以设置-XX:NewRatio=5,表示年老代/年轻代=5/1);
建议在开发测试环境可以用Xms和Xmx分别设置最小值最大值,但是在线上生产环境,Xms和Xmx设置的值必须一样,原因与年轻代一样——防止抖动;

-Xss
这个参数用于设置每个线程的栈内存,默认1M,一般来说是不需要改的。除非代码不多,可以设置的小点,另外一个相似的参数是-XX:ThreadStackSize,这两个参数在1.6以前,都是谁设置在后面,谁就生效;1.6版本以后,-Xss设置在后面,则以-Xss为准,-XXThreadStackSize设置在后面,则主线程以-Xss为准,其它线程以-XX:ThreadStackSize为准。

-Xrs
减少JVM对操作系统信号(OS Signals)的使用(JDK1.3.1之后才有效),当此参数被设置之后,jvm将不接收控制台的控制handler,以防止与在后台以服务形式运行的JVM冲突(这个用的比较少,参考:http://www.blogjava.net/midstr/archive/2008/09/21/230265.html)。

-Xprof
跟踪正运行的程序,并将跟踪数据在标准输出输出;适合于开发环境调试。

-Xnoclassgc
关闭针对class的gc功能;因为其阻止内存回收,所以可能会导致OutOfMemoryError错误,慎用;

-Xincgc
开启增量gc(默认为关闭);这有助于减少长时间GC时应用程序出现的停顿;但由于可能和应用程序并发执行,所以会降低CPU对应用的处理能力。

-Xloggc:file
与-verbose:gc功能类似,只是将每次GC事件的相关情况记录到一个文件中,文件的位置最好在本地,以避免网络的潜在问题。
若与verbose命令同时出现在命令行中,则以-Xloggc为准。

非Stable参数(非静态参数)
以-XX表示的非Stable参数,虽然在官方文档中是不确定的,不健壮的,各个公司的实现也各有不同,但往往非常实用,所以这部分参数对于GC非常重要。JVM(Hotspot)中主要的参数可以大致分为3类(参考http://blog.csdn.net/sfdev/article/details/2063928):

  • 性能参数( Performance Options):用于JVM的性能调优和内存分配控制,如初始化内存大小的设置;
  • 行为参数(Behavioral Options):用于改变JVM的基础行为,如GC的方式和算法的选择;
  • 调试参数(Debugging Options):用于监控、打印、输出等jvm参数,用于显示jvm更加详细的信息;

比较详细的非Stable参数总结,请参考Java 6 JVM参数选项大全(中文版)
对于非Stable参数,使用方法有4种:

  • -XX:+<option> 启用选项
  • -XX:-<option> 不启用选项
  • -XX:<option>=<number> 给选项设置一个数字类型值,可跟单位,例如 32k, 1024m, 2g
  • -XX:<option>=<string> 给选项设置一个字符串值,例如-XX:HeapDumpPath=./dump.core

首先介绍性能参数,性能参数往往用来定义内存分配的大小和比例,相比于行为参数和调试参数,一个比较明显的区别是性能参数后面往往跟的有数值,常用如下:

参数及其默认值 描述
-XX:NewSize=2.125m
新生代对象生成时占用内存的默认值
-XX:MaxNewSize=size 新生成对象能占用内存的最大值
-XX:MaxPermSize=64m 方法区所能占用的最大内存(非堆内存)
-XX:PermSize=64m 方法区分配的初始内存
-XX:MaxTenuringThreshold=15
对象在新生代存活区切换的次数(坚持过MinorGC的次数,每坚持过一次,该值就增加1),大于该值会进入老年代
-XX:MaxHeapFreeRatio=70
GC后java堆中空闲量占的最大比例,大于该值,则堆内存会减少
-XX:MinHeapFreeRatio=40 GC后java堆中空闲量占的最小比例,小于该值,则堆内存会增加
-XX:NewRatio=2 新生代内存容量与老生代内存容量的比例
-XX:ReservedCodeCacheSize= 32m 保留代码占用的内存容量
-XX:ThreadStackSize=512 设置线程栈大小,若为0则使用系统默认值
-XX:LargePageSizeInBytes=4m
设置用于Java堆的大页面尺寸
-XX:PretenureSizeThreshold= size 大于该值的对象直接晋升入老年代(这种对象少用为好)
-XX:SurvivorRatio=8 Eden区域Survivor区的容量比值,如默认值为8,代表Eden:Survivor1:Survivor2=8:1:1

常用的行为参数,主要用来选择使用什么样的垃圾收集器组合,以及控制运行过程中的GC策略等:

参数及其默认值 描述
-XX:-UseSerialGC
启用串行GC,即采用Serial+Serial Old模式
-XX:-UseParallelGC
启用并行GC,即采用Parallel Scavenge+Serial Old收集器组合(-Server模式下的默认组合)
-XX:GCTimeRatio=99 设置用户执行时间占总时间的比例(默认值99,即1%的时间用于GC)
-XX:MaxGCPauseMillis=time 设置GC的最大停顿时间(这个参数只对Parallel Scavenge有效)
-XX:+UseParNewGC 使用ParNew+Serial Old收集器组合
-XX:ParallelGCThreads 设置执行内存回收的线程数,在+UseParNewGC的情况下使用
-XX:+UseParallelOldGC
使用Parallel Scavenge +Parallel Old组合收集器
-XX:+UseConcMarkSweepGC 使用ParNew+CMS+Serial Old组合并发收集,优先使用ParNew+CMS,当用户线程内存不足时,采用备用方案Serial Old收集。
-XX:-DisableExplicitGC 禁止调用System.gc();但jvm的gc仍然有效
-XX:+ScavengeBeforeFullGC 新生代GC优先于Full GC执行

常用的调试参数,主要用于监控和打印GC的信息:

参数及其默认值 描述
-XX:-CITime 打印消耗在JIT编译的时间
-XX:ErrorFile=./hs_err_pid<pid>.log 保存错误日志或者数据到文件中
-XX:-ExtendedDTraceProbes 开启solaris特有的dtrace探针
-XX:HeapDumpPath=./java_pid<pid>.hprof 指定导出堆信息时的路径或文件名
-XX:-HeapDumpOnOutOfMemoryError 当首次遭遇OOM时导出此时堆中相关信息
-XX:OnError=”<cmd args>;<cmd args>” 出现致命ERROR之后运行自定义命令
-XX:OnOutOfMemoryError=”<cmd args>;<cmd args>” 当首次遭遇OOM时执行自定义命令
-XX:-PrintClassHistogram 遇到Ctrl-Break后打印类实例的柱状信息,与jmap -histo功能相同
-XX:-PrintConcurrentLocks 遇到Ctrl-Break后打印并发锁的相关信息,与jstack -l功能相同
-XX:-PrintCommandLineFlags 打印在命令行中出现过的标记
-XX:-PrintCompilation 当一个方法被编译时打印相关信息
-XX:-PrintGC 每次GC时打印相关信息
-XX:-PrintGC Details 每次GC时打印详细信息
-XX:-PrintGCTimeStamps 打印每次GC的时间戳
-XX:-TraceClassLoading 跟踪类的加载信息
-XX:-TraceClassLoadingPreorder 跟踪被引用到的所有类的加载信息
-XX:-TraceClassResolution 跟踪常量池
-XX:-TraceClassUnloading 跟踪类的卸载信息
-XX:-TraceLoaderConstraints 跟踪类加载器约束的相关信息

再次声明,上面的三种参数,主要参考了博客:http://blog.csdn.net/sfdev/article/details/2063928和http://kenwublog.com/docs/java6-jvm-options-chinese-edition.htm,后一个比较全面,有兴趣的可以仔细研读。
这些参数将为我们进行GC的监控与调优提供很大助力,是我们进行GC相关操作的重要工具。
收集器搭配

在介绍了常用的配置参数之后,我们将开始真正的JVM实操征程,首先,我们要为应用程序选择一个合适的垃圾收集器组合,本节请参考《Java系列笔记(3) – Java 内存区域和GC机制》一文中的“垃圾收集器”一节,及上节中的行为参数。

这里需要再次引用这幅图(图来源于《深入理解Java虚拟机:JVM高级特效与最佳实现》,图中两个收集器之间有连线,说明它们可以配合使用):


Serial收集器: Serial收集器是在client模式下默认的新生代收集器,其收集效率大约是100M左右的内存需要几十到100多毫秒;在client模式下,收集桌面应用的内存垃圾,基本上不影响用户体验。所以,一般的Java桌面应用中,直接使用Serial收集器(不需要配置参数,用默认即可)。
ParNew收集器:Serial收集器的多线程版本,这种收集器默认开通的线程数与CPU数量相同,-XX:ParallelGCThreads可以用来设置开通的线程数。
可以与CMS收集器配合使用,事实上用-XX:+UseConcMarkSweepGC选择使用CMS收集器时,默认使用的就是ParNew收集器,所以不需要额外设置-XX:+UseParNewGC,设置了也不会冲突,因为会将ParNew+Serial Old作为一个备选方案;
如果单独使用-XX:+UseParNewGC参数,则选择的是ParNew+Serial Old收集器组合收集器。
一般情况下,在server模式下,如果选择CMS收集器,则优先选择ParNew收集器。
Parallel Scavenge收集器:关注的是吞吐量(关于吞吐量的含义见上一篇博客),可以这么理解,关注吞吐量,意味着强调任务更快的完成,而如CMS等关注停顿时间短的收集器,强调的是用户交互体验。
在需要关注吞吐量的场合,比如数据运算服务器等,就可以使用Parallel Scavenge收集器。

老年代收集器如下:
Serial Old收集器:在1.5版本及以前可以与 Parallel Scavenge结合使用(事实上,也是当时Parallel Scavenge唯一能用的版本),另外就是在使用CMS收集器时的备用方案,发生 Concurrent Mode Failure时使用。
如果是单独使用,Serial Old一般用在client模式中。
Parallel Old收集器:在1.6版本之后,与 Parallel Scavenge结合使用,以更好的贯彻吞吐量优先的思想,如果是关注吞吐量的服务器,建议使用Parallel Scavenge + Parallel Old 收集器。
CMS收集器:这是当前阶段使用很广的一种收集器,国内很多大的互联网公司线上服务器都使用这种垃圾收集器(http://blog.csdn.net/wisgood/article/details/17067203),笔者公司的收集器也是这种,CMS收集器以获取最短回收停顿时间为目标,非常适合对用户响应比较高的B/S架构服务器。
 CMSIncrementalMode: CMS收集器变种,属增量式垃圾收集器,在并发标记和并发清理时交替运行垃圾收集器和用户线程。
 G1 收集器:面向服务器端应用的垃圾收集器,计划未来替代CMS收集器。

  • 一般来说,如果是Java桌面应用,建议采用Serial+Serial Old收集器组合,即:-XX:+UseSerialGC(-client下的默认参数)
  • 在开发/测试环境,可以采用默认参数,即采用Parallel Scavenge+Serial Old收集器组合,即:-XX:+UseParallelGC(-server下的默认参数)
  • 在线上运算优先的环境,建议采用Parallel Scavenge+Serial Old收集器组合,即:-XX:+UseParallelGC
  • 在线上服务响应优先的环境,建议采用ParNew+CMS+Serial Old收集器组合,即:-XX:+UseConcMarkSweepGC

另外在选择了垃圾收集器组合之后,还要配置一些辅助参数,以保证收集器可以更好的工作。关于这些参数,请在http://kenwublog.com/docs/java6-jvm-options-chinese-edition.htm中查询其意义和用法,如:

  • 选用了ParNew收集器,你可能需要配置4个参数: -XX:SurvivorRatio, -XX:PretenureSizeThreshold, -XX:+HandlePromotionFailure,-XX:MaxTenuringThreshold;
  • 选用了 Parallel Scavenge收集器,你可能需要配置3个参数: -XX:MaxGCPauseMillis,-XX:GCTimeRatio, -XX:+UseAdaptiveSizePolicy ;
  • 选用了CMS收集器,你可能需要配置3个参数: -XX:CMSInitiatingOccupancyFraction, -XX:+UseCMSCompactAtFullCollection, -XX:CMSFullGCsBeforeCompaction;

启动内存分配

关于GC有一个常见的疑问是,在启动时,我的内存如何分配?经过前面的学习,已经很容易知道,用-Xmn,-Xmx,-Xms,-Xss,-XX:NewSize,-XX:MaxNewSize,-XX:MaxPermSize,-XX:PermSize,-XX:SurvivorRatio,-XX:PretenureSizeThreshold,-XX:MaxTenuringThreshold就基本可以配置内存启动时的分配情况。但是,具体配置多少?设置小了,频繁GC(甚至内存溢出),设置大了,内存浪费。结合前面对于内存区域和其作用的学习,尽量考虑如下建议:

  1. -XX:PermSize尽量比-XX:MaxPermSize小,-XX:MaxPermSize>= 2 * -XX:PermSize, -XX:PermSize> 64m,一般对于4G内存的机器,-XX:MaxPermSize不会超过256m;
  2. -Xms =  -Xmx(线上Server模式),以防止抖动,大小受操作系统和内存大小限制,如果是32位系统,则一般-Xms设置为1g-2g(假设有4g内存),在64位系统上,没有限制,不过一般为机器最大内存的一半左右;
  3. -Xmn,在开发环境下,可以用-XX:NewSize和-XX:MaxNewSize来设置新生代的大小(-XX:NewSize<=-XX:MaxNewSize),在生产环境,建议只设置-Xmn,一般-Xmn的大小是-Xms的1/2左右,不要设置的过大或过小,过大导致老年代变小,频繁Full GC,过小导致minor GC频繁。如果不设置-Xmn,可以采用-XX:NewRatio=2来设置,也是一样的效果;
  4. -Xss一般是不需要改的,默认值即可。
  5. -XX:SurvivorRatio一般设置8-10左右,推荐设置为10,也即:Survivor区的大小是Eden区的1/10,一般来说,普通的Java程序应用,一次minorGC后,至少98%-99%的对象,都会消亡,所以,survivor区设置为Eden区的1/10左右,能使Survivor区容纳下10-20次的minor GC才满,然后再进入老年代,这个与 -XX:MaxTenuringThreshold的默认值15次也相匹配的。如果XX:SurvivorRatio设置的太小,会导致本来能通过minor回收掉的对象提前进入老年代,产生不必要的full gc;如果XX:SurvivorRatio设置的太大,会导致Eden区相应的被压缩。
  6. -XX:MaxTenuringThreshold默认为15,也就是说,经过15次Survivor轮换(即15次minor GC),就进入老年代, 如果设置的小的话,则年轻代对象在survivor中存活的时间减小,提前进入年老代,对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象在年轻代的存活时间,增加在年轻代即被回收的概率。需要注意的是,设置了 -XX:MaxTenuringThreshold,并不代表着,对象一定在年轻代存活15次才被晋升进入老年代,它只是一个最大值,事实上,存在一个动态计算机制,计算每次晋入老年代的阈值,取阈值和MaxTenuringThreshold中较小的一个为准。
  7. -XX:PretenureSizeThreshold一般采用默认值即可。

监控工具和方法

在JVM运行的过程中,为保证其稳定、高效,或在出现GC问题时分析问题原因,我们需要对GC进行监控。所谓监控,其实就是分析清楚当前GC的情况。其目的是鉴别JVM是否在高效的进行垃圾回收,以及有没有必要进行调优。
通过监控GC,我们可以搞清楚很多问题,如:
1,minor GC和full GC的频率;
2,执行一次GC所消耗的时间;
3,新生代的对象何时被移到老生代以及花费了多少时间;
4,每次GC中,其它线程暂停(Stop the world)的时间;
5,每次GC的效果如何,是否不理想;
………………
监控GC的工具分为2种:命令行工具和图形工具;
常用的命令行工具有:
注:下面的命令都在JAVA_HOME/bin中,是java自带的命令。如果您发现无法使用,请直接进入Java安装目录调用或者先设置Java的环境变量,一个简单的办法为:直接运行命令 export PATH=$JAVA_HOME/bin:$PATH;另外,一般的,在Linux下,下面的命令需要sudo权限,在windows下,部分命令的部分选项不能使用。
1,jps
jps命令用于查询正在运行的JVM进程,常用的参数为:
-q:只输出LVMID,省略主类的名称
-m:输出虚拟机进程启动时传给主类main()函数的参数
-l:输出主类的全类名,如果进程执行的是Jar包,输出Jar路径
-v:输出虚拟机进程启动时JVM参数
命令格式:jps [option] [hostid]
一个简单的例子:


在上图中,有一个vid为309的apache进程在提供web服务。

2,jstat
jstat可以实时显示本地或远程JVM进程中类装载、内存、垃圾收集、JIT编译等数据(如果要显示远程JVM信息,需要远程主机开启RMI支持)。如果在服务启动时没有指定启动参数-verbose:gc,则可以用jstat实时查看gc情况。
jstat有如下选项:
-class:监视类装载、卸载数量、总空间及类装载所耗费的时间
-gc:监听Java堆状况,包括Eden区、两个Survivor区、老年代、永久代等的容量,以用空间、GC时间合计等信息
-gccapacity:监视内容与-gc基本相同,但输出主要关注java堆各个区域使用到的最大和最小空间
-gcutil:监视内容与-gc基本相同,但输出主要关注已使用空间占总空间的百分比
-gccause:与-gcutil功能一样,但是会额外输出导致上一次GC产生的原因
-gcnew:监视新生代GC状况
-gcnewcapacity:监视内同与-gcnew基本相同,输出主要关注使用到的最大和最小空间
-gcold:监视老年代GC情况
-gcoldcapacity:监视内同与-gcold基本相同,输出主要关注使用到的最大和最小空间
-gcpermcapacity:输出永久代使用到最大和最小空间
-compiler:输出JIT编译器编译过的方法、耗时等信息
-printcompilation:输出已经被JIT编译的方法
命令格式:jstat [option vmid [interval[s|ms] [count]]]
jstat可以监控远程机器,命令格式中VMID和LVMID特别说明:如果是本地虚拟机进程,VMID和LVMID是一致的,如果是远程虚拟机进程,那么VMID格式是: [protocol:][//]lvmid[@hostname[:port]/servername],如果省略interval和count,则只查询一次
查看gc情况的例子:


在图中,命令sudo jstat -gc 309 1000 5代表着:搜集vid为309的java进程的整体gc状态, 每1000ms收集一次,共收集5次;XXXC表示该区容量,XXXU表示该区使用量,各列解释如下:
S0C:S0区容量(S1区相同,略)
S0U:S0区已使用
EC:E区容量
EU:E区已使用
OC:老年代容量
OU:老年代已使用
PC:Perm容量
PU:Perm区已使用
YGC:Young GC(Minor GC)次数
YGCT:Young GC总耗时
FGC:Full GC次数
FGCT:Full GC总耗时
GCT:GC总耗时

用gcutil查看内存的例子:


图中的各列与用gc参数时基本一致,不同的是这里显示的是已占用的百分比,如S0为86.53,代表着S0区已使用了86.53%

3,jinfo
用于查询当前运行这的JVM属性和参数的值。
jinfo可以使用如下选项:
-flag:显示未被显示指定的参数的系统默认值
-flag [+|-]name或-flag name=value: 修改部分参数
-sysprops:打印虚拟机进程的System.getProperties()
命令格式:jinfo [option] pid

4,jmap
用于显示当前Java堆和永久代的详细信息(如当前使用的收集器,当前的空间使用率等)
   -dump:生成java堆转储快照
   -heap:显示java堆详细信息(只在Linux/Solaris下有效)
   -F:当虚拟机进程对-dump选项没有响应时,可使用这个选项强制生成dump快照(只在Linux/Solaris下有效)
-finalizerinfo:显示在F-Queue中等待Finalizer线程执行finalize方法的对象(只在Linux/Solaris下有效)
-histo:显示堆中对象统计信息
-permstat:以ClassLoader为统计口径显示永久代内存状态(只在Linux/Solaris下有效)
命令格式:jmap [option] vmid
其中前面3个参数最重要,如:
查看对详细信息:sudo jmap -heap 309
生成dump文件: sudo jmap -dump:file=./test.prof 309
部分用户没有权限时,采用admin用户:sudo -u admin -H  jmap -dump:format=b,file=文件名.hprof pid
查看当前堆中对象统计信息:sudo  jmap -histo 309:该命令显示3列,分别为对象数量,对象大小,对象名称,通过该命令可以查看是否内存中有大对象;
有的用户可能没有jmap权限:sudo -u admin -H jmap -histo 309 | less

5,jhat
用于分析使用jmap生成的dump文件,是JDK自带的工具,使用方法为: jhat -J -Xmx512m [file]
不过jhat没有mat好用,推荐使用mat(Eclipse插件: http://www.eclipse.org/mat ),mat速度更快,而且是图形界面。

6,jstack
用于生成当前JVM的所有线程快照,线程快照是虚拟机每一条线程正在执行的方法,目的是定位线程出现长时间停顿的原因。
-F:当正常输出的请求不被响应时,强制输出线程堆栈
-l:除堆栈外,显示关于锁的附加信息
-m:如果调用到本地方法的话,可以显示C/C++的堆栈
命令格式:jstack [option] vmid

7,-verbosegc
-verbosegc是一个比较重要的启动参数,记录每次gc的日志,下面的表格对比了jstat和-verbosegc:

jstat
-verbosegc
监控对象
运行在本机的Java应用可以把日志输出到终端上,或者借助jstatd命令通过网络连接远程的Java应用。
只有那些把-verbogc作为启动参数的JVM。
输出信息
堆状态(已用空间,最大限制,GC执行次数/时间,等等)
执行GC前后新生代和老年代空间大小,GC执行时间。
输出时间
Every designated time
每次设定好的时间。
每次GC发生的时候。
用途
观察堆空间变化情况
了解单次GC产生的效果。

与-verbosegc配合使用的一些常用参数为:
-XX:+PrintGCDetails,打印GC信息,这是-verbosegc默认开启的选项
-XX:+PrintGCTimeStamps,打印每次GC的时间戳
-XX:+PrintHeapAtGC:每次GC时,打印堆信息
-XX:+PrintGCDateStamps (from JDK 6 update 4) :打印GC日期,适合于长期运行的服务器
-Xloggc:/home/admin/logs/gc.log:制定打印信息的记录的日志位置
每条verbosegc打印出的gc日志,都类似于下面的格式:
time [GC [<collector>: <starting occupancy1> -> <ending occupancy1>(total occupancy1), <pause time1> secs] <starting occupancy3> -> <ending occupancy3>(total occupancy3), <pause time3> secs]
如:


这些选项的意义是:
time:执行GC的时间,需要添加-XX:+PrintGCDateStamps参数才有;
collector:minor gc使用的收集器的名字。
starting occupancy1:GC执行前新生代空间大小。
ending occupancy1:GC执行后新生代空间大小。
total occupancy1:新生代总大小
pause time1:因为执行minor GC,Java应用暂停的时间。
starting occupancy3:GC执行前堆区域总大小
ending occupancy3:GC执行后堆区域总大小
total occupancy3:堆区总大小
pause time3:Java应用由于执行堆空间GC(包括full GC)而停止的时间。

8,可视化工具
监控和分析GC也有一些可视化工具,比较常见的有JConsole和VisualVM,有兴趣的可以看看下面的文章,在此不再赘述:
http://blog.csdn.net/java2000_wl/article/details/8049707

调优方法

一切都是为了这一步,调优,在调优之前,我们需要记住下面的原则:

  1. 多数的Java应用不需要在服务器上进行GC优化;
  2. 多数导致GC问题的Java应用,都不是因为我们参数设置错误,而是代码问题;
  3. 在应用上线之前,先考虑将机器的JVM参数设置到最优(最适合);
  4. 减少创建对象的数量;
  5. 减少使用全局变量和大对象;
  6. GC优化是到最后不得已才采用的手段;
  7. 在实际使用中,分析GC情况优化代码比优化GC参数要多得多;

GC优化的目的有两个(http://www.360doc.com/content/13/0305/10/15643_269388816.shtml):

  • 将转移到老年代的对象数量降低到最小;
  • 减少full GC的执行时间;

为了达到上面的目的,一般地,你需要做的事情有:

  • 减少使用全局变量和大对象;
  • 调整新生代的大小到最合适;
  • 设置老年代的大小为最合适;
  • 选择合适的GC收集器;

在上面的4条方法中,用了几个“合适”,那究竟什么才算合适,一般的,请参考上面“收集器搭配”和“启动内存分配”两节中的建议。但这些建议不是万能的,需要根据您的机器和应用情况进行发展和变化,实际操作中,可以将两台机器分别设置成不同的GC参数,并且进行对比,选用那些确实提高了性能或减少了GC时间的参数。

真正熟练的使用GC调优,是建立在多次进行GC监控和调优的实战经验上的,进行监控和调优的一般步骤为:
1,监控GC的状态
使用各种JVM工具,查看当前日志,分析当前JVM参数设置,并且分析当前堆内存快照和gc日志,根据实际的各区域内存划分和GC执行时间,觉得是否进行优化;
2,分析结果,判断是否需要优化
如果各项参数设置合理,系统没有超时日志出现,GC频率不高,GC耗时不高,那么没有必要进行GC优化;如果GC时间超过1-3秒,或者频繁GC,则必须优化;
注:如果满足下面的指标,则一般不需要进行GC:

  • Minor GC执行时间不到50ms;
  • Minor GC执行不频繁,约10秒一次;
  • Full GC执行时间不到1s;
  • Full GC执行频率不算频繁,不低于10分钟1次;

3,调整GC类型和内存分配
如果内存分配过大或过小,或者采用的GC收集器比较慢,则应该优先调整这些参数,并且先找1台或几台机器进行beta,然后比较优化过的机器和没有优化的机器的性能对比,并有针对性的做出最后选择;
4,不断的分析和调整
通过不断的试验和试错,分析并找到最合适的参数
5,全面应用参数
如果找到了最合适的参数,则将这些参数应用到所有服务器,并进行后续跟踪。

调优实例

上面的内容都是纸上谈兵,下面我们以一些真实例子来进行说明:
实例1:
笔者昨日发现部分开发测试机器出现异常:java.lang.OutOfMemoryError: GC overhead limit exceeded,这个异常代表:GC为了释放很小的空间却耗费了太多的时间,其原因一般有两个:1,堆太小,2,有死循环或大对象;
笔者首先排除了第2个原因,因为这个应用同时是在线上运行的,如果有问题,早就挂了。所以怀疑是这台机器中堆设置太小;
使用ps -ef |grep “java”查看,发现:


该应用的堆区设置只有768m,而机器内存有2g,机器上只跑这一个java应用,没有其他需要占用内存的地方。另外,这个应用比较大,需要占用的内存也比较多;
笔者通过上面的情况判断,只需要改变堆中各区域的大小设置即可,于是改成下面的情况:


跟踪运行情况发现,相关异常没有再出现;

实例2:(http://www.360doc.com/content/13/0305/10/15643_269388816.shtml)
一个服务系统,经常出现卡顿,分析原因,发现Full GC时间太长:
jstat -gcutil:
S0     S1    E     O       P        YGC YGCT FGC FGCT  GCT
12.16 0.00 5.18 63.78 20.32  54   2.047 5     6.946  8.993
分析上面的数据,发现Young GC执行了54次,耗时2.047秒,每次Young GC耗时37ms,在正常范围,而Full GC执行了5次,耗时6.946秒,每次平均1.389s,数据显示出来的问题是:Full GC耗时较长,分析该系统的是指发现,NewRatio=9,也就是说,新生代和老生代大小之比为1:9,这就是问题的原因:
1,新生代太小,导致对象提前进入老年代,触发老年代发生Full GC;
2,老年代较大,进行Full GC时耗时较大;
优化的方法是调整NewRatio的值,调整到4,发现Full GC没有再发生,只有Young GC在执行。这就是把对象控制在新生代就清理掉,没有进入老年代(这种做法对一些应用是很有用的,但并不是对所有应用都要这么做)

实例3:
一应用在性能测试过程中,发现内存占用率很高,Full GC频繁,使用sudo -u admin -H  jmap -dump:format=b,file=文件名.hprof pid 来dump内存,生成dump文件,并使用Eclipse下的mat差距进行分析,发现:


从图中可以看出,这个线程存在问题,队列LinkedBlockingQueue所引用的大量对象并未释放,导致整个线程占用内存高达378m,此时通知开发人员进行代码优化,将相关对象释放掉即可。

说明

本文是Java系列笔记的第4篇,这篇文章写了近3个月,一方面是这部分对我来说也是学习阶段,另一方面是这段时间一直在做项目,直到最近才比较有时间。
本人能力有限,如果有错漏,请留言指正。

参考资料

《深入理解Java虚拟机:JVM高级特效与最佳实现》
JVM启动参数大全, http://www.blogjava.net/midstr/archive/2008/09/21/230265.html
JVM系列三:JVM参数设置、分析, http://www.cnblogs.com/redcreen/archive/2011/05/04/2037057.html
Java 6 JVM参数选项大全(中文版), http://kenwublog.com/docs/java6-jvm-options-chinese-edition.htm
成为JavaGC专家Part II — 如何监控Java垃圾回收机制, http://www.importnew.com/2057.html
成为Java GC专家系列(3) — 如何优化Java垃圾回收机制, http://www.importnew.com/3146.html
JDK5.0垃圾收集优化之–Don’t Pause, http://calvin.iteye.com/blog/91905
Java HOTSPOT VM参数大全, http://tech.sina.com.cn/s/2009-09-23/09561077572.shtml
【原】GC的默认方式, http://iamzhongyong.iteye.com/blog/1447314
JAVA启动参数大全之三:非Stable参数, http://blog.csdn.net/sfdev/article/details/2063928
Java虚拟机学习 – 内存调优, http://blog.csdn.net/java2000_wl/article/details/8090940
内存溢出, http://www.open-open.com/home/space.php?uid=71669&do=blog&id=8891
如何查看JVM的扩展参数:-X, http://www.blogjava.net/beansoft/archive/2012/03/01/371088.html
JVM内存状况查看方法和分析工具, http://hi.baidu.com/kingfly666666/item/e710a4371c60b0f1e7bb7a32
虚拟机学习系列 – 附 – 虚拟机参数, http://blog.csdn.net/su1216/article/details/7780924
JVM系列四:生产环境参数实例及分析【生产环境实例增加中】, http://www.cnblogs.com/redcreen/archive/2011/05/05/2038331.html
垃圾收集器与内存分配策略, http://raging-sweet.iteye.com/blog/1170198
JVM垃圾收集器使用调查:CMS最受欢迎 , http://blog.csdn.net/wisgood/article/details/17067203
Xms Xmx PermSize MaxPermSize 区别, http://www.cnblogs.com/mingforyou/archive/2012/03/03/2378143.html
Java虚拟机学习 – JDK可视化监控工具, http://blog.csdn.net/java2000_wl/article/details/8049707
虚拟机学习系列 – 6 – JDK工具, http://blog.csdn.net/su1216/article/details/7780857
JVM监控工具介绍jstack, jconsole, jinfo, jmap, jdb, jstat, http://hi.baidu.com/lotusxyhf/item/9cd8fcb8d6f8c1a5ebba935b
JVM 与 jstat, http://blog.sina.com.cn/s/blog_56fcfd620100hdcp.html

基于微服务的软件架构模式

原文链接:http://www.jianshu.com/p/546ef242b6a3

今天阅读了两篇关于微服务的文章,总结一些笔记,不敢贸然翻译:一是因为水平不够,翻译的过程会丢掉作者的原意;二是因为技术翻译是一个略微吃力不讨好的活。

微服务(micro services)这个概念不是新概念,很多公司已经在实践了,例如亚马逊、Google、FaceBook,Alibaba。微服务架构模式(Microservices Architecture Pattern)的目的是将大型的、复杂的、长期运行的应用程序构建为一组相互配合的服务,每个服务都可以很容易得局部改良。 Micro这个词意味着每个服务都应该足够小,但是,这里的小不能用代码量来比较,而应该是从业务逻辑上比较——符合SRP原则的才叫微服务。

暂且不讨论大小问题,读者朋友你首先要考虑的是如何解决目前技术团队遇到的开发问题、部署问题。正是在解决这些问题的过程中,才渐渐总结提炼出了微服务架构模式的概念。

微服务跟SOA有什么区别呢,可以把微服务当做去除了ESB的SOA。ESB是SOA架构中的中心总线,设计图形应该是星形的,而微服务是去中心化的分布式软件架构。

接下来会讨论以下几个话题:

  1. 应用微服务的动机,跟传统巨石应用的比较
  2. 微服务的优点与缺点
  3. 应用微服务架构设计时可能遇到的关键问题(内部服务通信、分布式数据管理)

一、巨石(monolith)

web应用程序发展的早期,大部分web工程是将所有的功能模块(service side)打包到一起并放在一个web容器中运行,很多企业的Java应用程序打包为war包。其他语言(Ruby,Python或者C++)写的程序也有类似的问题。

假设你正在构建一个在线商店系统:客户下订单、核对清单和信用卡额度,并将货物运输给客户。很快,你们团队一定能构造出如下图所示的系统。

Fig1- the monolithic architecture

这种将所有功能都部署在一个web容器中运行的系统就叫做巨石型应用。巨石型应用有很多好处:IDE都是为开发单个应用设计的、容易测试——在本地就可以启动完整的系统、容易部署——直接打包为一个完整的包,拷贝到web容器的某个目录下即可运行。

但是,上述的好处是有条件的:应用不那么复杂。对于大规模的复杂应用,巨石型应用会显得特别笨重:要修改一个地方就要将整个应用全部部署(PS:在不同的场景下优势也变成了劣势);编译时间过长;回归测试周期过长;开发效率降低等。另外,巨石应用不利于更新技术框架,除非你愿意将系统全部重写(代价太高你愿意老板也不愿意)。

二、应用拆分

详细一个网站在业务大规模爬升时会发生什么事情?并发度不够?OK,加web服务器。数据库压力过大?OK,买更大更贵的数据库。数据库太贵了?将一个表的数据分开存储,俗称“分库分表”。这些都没有问题,good job。不过,老外的抽象能力比我们强,看下图Fig2。

Fig2 – the scale cube

这张图从三个维度概括了一个系统的扩展过程:(1)x轴,水平复制,即在负载均衡服务器后增加多个web服务器;(2)z轴扩展,是对数据库的扩展,即分库分表(分库是将关系紧密的表放在一台数据库服务器上,分表是因为一张表的数据太多,需要将一张表的数据通过hash放在不同的数据库服务器上);(3)y轴扩展,是功能分解,将不同职能的模块分成不同的服务。从y轴这个方向扩展,才能将巨型应用分解为一组不同的服务,例如订单管理中心、客户信息管理中心、商品管理中心等等。

将系统划分为不同的服务有很多方法:(1)按照用例划分,例如在线商店系统中会划分出一个checkout UI服务,这个服务实现了checkout这个用例;(2)按照资源划分,例如可以划分出一个catlog服务来存储产品目录。

服务划分有两个原则要遵循:(1)每个服务应该尽可能符合单一职责原则——Single Responsible Principle,即每个服务只做一件事,并把这件事做好;(2)参考Unix命令行工具的设计,Unix提供了大量的简单易用的工具,例如grep、cat和find。每个工具都小而美。

最后还要强调:系统分解的目标并不仅仅是搞出一堆很小的服务,这不是目标;真正的目标是解决巨石型应用在业务急剧增长时遇到的问题。

对于上面的例子,按照功能和资源划分后,就形成下面图3的架构图。分解后的微服务架构包含多个前端服务和后端服务。前端服务包括Catalog UI(用于商品搜索和浏览)、Checkout UI(用于实现购物车和下单操作);后端服务包括一些业务逻辑模块,我们将在巨石应用中的每个服务模块重构为一个单独的服务。这么做有什么问题呢?

Fig 3 – the microservice architecture

三、微服务架构的优点与缺点

1. 优点

  • 每个服务足够内聚,足够小,代码容易理解、开发效率提高
  • 服务之间可以独立部署,微服务架构让持续部署成为可能;
  • 每个服务可以各自进行x扩展和z扩展,而且,每个服务可以根据自己的需要部署到合适的硬件服务器上;
  • 容易扩大开发团队,可以针对每个服务(service)组件开发团队;
  • 提高容错性(fault isolation),一个服务的内存泄露并不会让整个系统瘫痪;
  • 系统不会被长期限制在某个技术栈上。

2. 缺点

《人月神话》中讲到:没有银弹,意思是只靠一把锤子是盖不起摩天大楼的,要根据业务场景选择设计思路和实现工具。我们看下为了换回上面提到的好处,我们付出(trade)了什么?

  • 开发人员要处理分布式系统的复杂性;开发人员要设计服务之间的通信机制,对于需要多个后端服务的user case,要在没有分布式事务的情况下实现代码非常困难;涉及多个服务直接的自动化测试也具备相当的挑战性;
  • 服务管理的复杂性,在生产环境中要管理多个不同的服务的实例,这意味着开发团队需要全局统筹(PS:现在docker的出现适合解决这个问题
  • 应用微服务架构的时机如何把握?对于业务还没有理清楚、业务数据和处理能力还没有开始爆发式增长之前的创业公司,不需要考虑微服务架构模式,这时候最重要的是快速开发、快速部署、快速试错。

四、微服务架构的关键问题

1. 微服务架构的通信机制

(1)客户端与服务器之间的通信

在巨石型架构下,客户端应用程序(web或者app)通过向服务端发送HTTP请求;但是,在微服务架构下,原来的巨石型服务器被一组微服务替代,这种情况下客户端如何发起请求呢?

如图4中所示,客户端可以向micro service发起RESTful HTTP请求,但是会有这种情况发生:客户端为了完成一个业务逻辑,需要发起多个HTTP请求,从而造成系统的吞吐率下降,再加上无线网络的延迟高,会严重影响客户端的用户体验。

Fig4 – calling services directly

为了解决这个问题,一般会在服务器集群前面再加一个角色:API gateway,由它负责与客户度对接,并将客户端的请求转化成对内部服务的一系列调用。这样做还有个好处是,服务升级不会影响到客户端,只需要修改API gateway即可。加了API gateway之后的系统架构图如图5所示。

Fig5 – API gateway

(2)内部服务之间的通信

内部服务之间的通信方式有两种:基于HTTP协议的同步机制(REST、RPC);基于消息队列的异步消息处理机制(AMQP-based message broker)。

Dubbo是阿里巴巴开源的分布式服务框架,属于同步调用,当一个系统的服务太多时,需要一个注册中心来处理服务发现问题,例如使用ZooKeeper这类配置服务器进行服务的地址管理:服务的发布者要向ZooKeeper发送请求,将自己的服务地址和函数名称等信息记录在案;服务的调用者要知道服务的相关信息,具体的机器地址在ZooKeeper查询得到。这种同步的调用机制足够直观简单,只是没有“订阅——推送”机制。

AMQP-based的代表系统是kafkaRabbitMQ等。这类分布式消息处理系统将订阅者和消费者解耦合,消息的生产者不需要消费者一直在线;消息的生产者只需要把消息发送给消息代理,因此也不需要服务发现机制。

两种通信机制都有各自的优点和缺点,实际中的系统经常包含两种通信机制。例如,在分布式数据管理中,就需要同时用到同步HTTP机制和异步消息处理机制。

2. 分布式数据管理

(1)处理读请求

在线商店的客户账户有限额,当客户试图下单时,系统必须判断总的订单金额是否超过他的信用卡额度。信用卡额度由CustomerService管理、下订单的操作由OrderService负责,因此Order Service要通过RPC调用向Customer Service请求数据;这种方法能够保证每次Order Service都获取到准确的额度,单缺点是多一次RPC调用、而且Customer Service必须保持在线。

还有一种处理方式是,在OrderService这边存放一份信用卡额度的副本,这样就不需要实时发起RPC请求,但是还需要一种机制保证——当Customer Service拥有的信用卡额度发生变化时,要及时更新存放在Order Service这边的副本。

(2)处理更新请求

当一份数据位于多个服务上时,必须保证数据的一致性。

  • 分布式事务(Distributed transactions)
    使用分布式事务非常直观,即要更新Customer Service上的信用卡额度,就必须同时更新其他服务上的副本,这些操作要么全做要么全不做。使用分布式事务能够保证数据的强一致,但是会降低系统的可用性——所有相关的服务必须始终在线;而且,很多现代的技术栈并不支持事务,例如REST、NoSQL数据库等。
  • 基于事件的异步更新(Event-driven asynchronous updates)
    Customer Service中的信用卡额度改变时,它对外发布一个事件到“message broker(消息代理人)”;其他订阅了这个事件的服务受到提示后就更新数据。事件流如图6所示。

    Fig 6 – replicating the credit limit using events

五、重构巨石型应用

在实际工作中,很少有机会参与一个全新的项目,需要处理的差不多都是存在这样那样问题的复杂、大型应用。这时候如何在维护老服务的同时,将系统渐渐重构为微服务架构呢?

  1. 不要让事情更坏,有新的需求过来时,如果可以独立开发为一个服务,就单独开发,然后为老服务和新服务直接编写胶水代码(Glue Code)——这个过程不容易,但这是分解巨型服务的第一步,如图7所示;

    Fig-7 – extracting a service
  2. 识别巨石型应用中的可以分离出来当做单独服务的模块,一般适合分离的模块具有如下特点:两个模块对资源的需求是冲突的(一个是CPU密集型、一个是IO密集型);授权鉴定层也适合单独分离出一个服务。每分离出一个服务,就需要编写对应的胶水代码来与剩下的服务通信,这样,在逐渐演进过程中,就完成了整个系统的架构更新。

关于重构,有篇文章推荐大家阅读——推倒重来的讲究,关于重构有很多可以写的,希望我能快速进步,多写点总结与大家分享。

总结

微服务并不是治百病的良药,也不是什么新的技术,我从中学到的最大的一点就是scale cube,从这个坐标轴出发去考虑大规模系统的构建比较容易分析和实践。

 

文/杜琪(简书作者)
原文链接:http://www.jianshu.com/p/546ef242b6a3
著作权归作者所有,转载请联系作者获得授权,并标注“简书作者”。

优化MySQL中的分页

一道面试的问题,当MySQL表中有数据量很大的时候如何做分页。。。。当时只知道在数据量很大的时候可以分表,但不知道不分表时可以怎么做。。。。唉,谁让代理商就那么几条数据,一个简单的limit,offset就完全hold住了(捂脸🙈)。。。

翻译一篇关于优化MySQL中的分页的文章,原文地址:Optimized Pagination using MySQL,谢谢,3Q,康桑阿米达~~~

很多应用往往只展示最新或最热门的几条记录,但为了旧记录仍然可访问,所以就需要个分页的导航栏。然而,如何通过MySQL更好的实现分页,始终是比较令人头疼的问题。虽然没有拿来就能用的解决办法,但了解数据库的底层或多或少有助于优化分页查询。
我们先从一个常用但性能很差的查询来看一看。

1
2
3
4
SELECT *
FROM city
ORDER BY id DESC
LIMIT 0, 15

这个查询耗时0.00sec。So,这个查询有什么问题呢?实际上,这个查询语句和参数都没有问题,因为它用到了下面表的主键,而且只读取15条记录。

1
2
3
4
5
CREATE TABLE city (
  id int(10) unsigned NOT NULL AUTO_INCREMENT,
  city varchar(128) NOT NULL,
  PRIMARY KEY (id)
) ENGINE=InnoDB;

真正的问题在于offset(分页偏移量)很大的时候,像下面这样:

1
2
3
4
SELECT *
FROM city
ORDER BY id DESC
LIMIT 100000, 15;

上面的查询在有2M行记录时需要0.22sec,通过EXPLAIN查看SQL的执行计划可以发现该SQL检索了100015行,但最后只需要15行。大的分页偏移量会增加使用的数据,MySQL会将大量最终不会使用的数据加载到内存中。就算我们假设大部分网站的用户只访问前几页数据,但少量的大的分页偏移量的请求也会对整个系统造成危害。Facebook意识到了这一点,但Facebook并没有为了每秒可以处理更多的请求而去优化数据库,而是将重心放在将请求响应时间的方差变小
对于分页请求,还有一个信息也很重要,就是总共的记录数。我们可以通过下面的查询很容易的获取总的记录数。

1
2
SELECT COUNT(*)
FROM city;

然而,上面的SQL在采用InnoDB为存储引擎时需要耗费9.28sec。一个不正确的优化是采用SQL_CALC_FOUND_ROWS,SQL_CALC_FOUND_ROWS可以在能够在分页查询时事先准备好符合条件的记录数,随后只要执行一句select FOUND_ROWS(); 就能获得总记录数。但是在大多数情况下,查询语句简短并不意味着性能的提高。不幸的是,这种分页查询方式在许多主流框架中都有用到,下面看看这个语句的查询性能。

1
2
3
4
SELECT SQL_CALC_FOUND_ROWS *
FROM city
ORDER BY id DESC
LIMIT 100000, 15;

这个语句耗时20.02sec,是上一个的两倍。事实证明使用SQL_CALC_FOUND_ROWS做分页是很糟糕的想法。
下面来看看到底如何优化。文章分为两部分,第一部分是如何获取记录的总数目,第二部分是获取真正的记录。

高效的计算行数

如果采用的引擎是MyISAM,可以直接执行COUNT(*)去获取行数即可。相似的,在堆表中也会将行数存储到表的元信息中。但如果引擎是InnoDB情况就会复杂一些,因为InnoDB不保存表的具体行数。
我们可以将行数缓存起来,然后可以通过一个守护进程定期更新或者用户的某些操作导致缓存失效时,执行下面的语句:

1
2
3
SELECT COUNT(*)
FROM city
USE INDEX(PRIMARY);

获取记录

下面进入这篇文章最重要的部分,获取分页要展示的记录。上面已经说过了,大的偏移量会影响性能,所以我们要重写查询语句。为了演示,我们创建一个新的表“news”,按照时事性排序(最新发布的在最前面),实现一个高性能的分页。为了简单,我们就假设最新发布的新闻的Id也是最大的。

1
2
3
4
CREATE TABLE news(
   id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
   title VARCHAR(128) NOT NULL
) ENGINE=InnoDB;

一个比较高效的方式是基于用户展示的最后一个新闻Id。查询下一页的语句如下,需要传入当前页面展示的最后一个Id。

1
2
3
4
SELECT *
FROM news WHERE id < $last_id
ORDER BY id DESC
LIMIT $perpage

查询上一页的语句类似,只不过需要传入当前页的第一个Id,并且要逆序。

1
2
3
4
SELECT *
FROM news WHERE id > $last_id
ORDER BY id ASC
LIMIT $perpage

上面的查询方式适合实现简易的分页,即不显示具体的页数导航,只显示“上一页”和“下一页”,例如博客中页脚显示“上一页”,“下一页”的按钮。但如果要实现真正的页面导航还是很难的,下面看看另一种方式。

1
2
3
4
5
6
7
8
9
10
SELECT id
FROM (
   SELECT id, ((@cnt:= @cnt + 1) + $perpage - 1) % $perpage cnt
   FROM news 
   JOIN (SELECT @cnt:= 0)T
   WHERE id < $last_id
   ORDER BY id DESC
   LIMIT $perpage * $buttons
)C
WHERE cnt = 0;

通过上面的语句可以为每一个分页的按钮计算出一个offset对应的id。这种方法还有一个好处。假设,网站上正在发布一片新的文章,那么所有文章的位置都会往后移一位,所以如果用户在发布文章时换页,那么他会看见一篇文章两次。如果固定了每个按钮的offset Id,这个问题就迎刃而解了。Mark Callaghan发表过一篇类似的博客,利用了组合索引和两个位置变量,但是基本思想是一致的。
如果表中的记录很少被删除、修改,还可以将记录对应的页码存储到表中,并在该列上创建合适的索引。采用这种方式,当新增一个记录的时候,需要执行下面的查询重新生成对应的页号。

1
2
SET @p:= 0;
UPDATE news SET page=CEIL((@p:= @p + 1) / $perpage) ORDER BY id DESC;

当然,也可以新增一个专用于分页的表,可以用个后台程序来维护。

1
2
3
4
5
6
7
8
UPDATE pagination T
JOIN (
   SELECT id, CEIL((@p:= @p + 1) / $perpage) page
   FROM news
   ORDER BY id
)C
ON C.id = T.id
SET T.page = C.page;

现在想获取任意一页的元素就很简单了:

1
2
3
4
SELECT *
FROM news A
JOIN pagination B ON A.id=B.ID
WHERE page=$offset;

还有另外一种与上种方法比较相似的方法来做分页,这种方式比较试用于数据集相对小,并且没有可用的索引的情况下—比如处理搜索结果时。在一个普通的服务器上执行下面的查询,当有2M条记录时,要耗费2sec左右。这种方式比较简单,创建一个用来存储所有Id的临时表即可(这也是最耗费性能的地方)。

1
2
3
4
5
CREATE TEMPORARY TABLE _tmp (KEY SORT(random))
SELECT id, FLOOR(RAND() * 0x8000000) random
FROM city;

ALTER TABLE _tmp ADD OFFSET INT UNSIGNED PRIMARY KEY AUTO_INCREMENT, DROP INDEX SORT, ORDER BY random;

接下来就可以向下面一样执行分页查询了。

1
2
3
4
5
SELECT *
FROM _tmp
WHERE OFFSET >= $offset
ORDER BY OFFSET
LIMIT $perpage;

——————————————–俺只是个分割线———————————————————-
简单来说,对于分页的优化就是。。。避免数据量大时扫描过多的记录。
博客比较长,所以翻译的有些粗糙。。。,之后会在好好检查一遍的。在自己做测试时,有些查询时间与作者有点不一致,不过作者这篇博客是写于2011年的,so~不要在意具体数据,领会精神吧~~

MySQL索引原理及慢查询优化

原文出处: 美团技术博客

MySQL凭借着出色的性能、低廉的成本、丰富的资源,已经成为绝大多数互联网公司的首选关系型数据库。虽然性能出色,但所谓“好马配好鞍”,如何能够更好的使用它,已经成为开发工程师的必修课,我们经常会从职位描述上看到诸如“精通MySQL”、“SQL语句优化”、“了解数据库原理”等要求。我们知道一般的应用系统,读写比例在10:1左右,而且插入操作和一般的更新操作很少出现性能问题,遇到最多的,也是最容易出问题的,还是一些复杂的查询操作,所以查询语句的优化显然是重中之重。

本人从13年7月份起,一直在美团核心业务系统部做慢查询的优化工作,共计十余个系统,累计解决和积累了上百个慢查询案例。随着业务的复杂性提升,遇到的问题千奇百怪,五花八门,匪夷所思。本文旨在以开发工程师的角度来解释数据库索引的原理和如何优化慢查询。

一个慢查询引发的思考
select count(*) from task where status=2 and operator_id=20839 and operate_time>1371169729 and operate_time<1371174603 and type=2;
系统使用者反应有一个功能越来越慢,于是工程师找到了上面的SQL。
并且兴致冲冲的找到了我,“这个SQL需要优化,给我把每个字段都加上索引”
我很惊讶,问道“为什么需要每个字段都加上索引?”
“把查询的字段都加上索引会更快”工程师信心满满
“这种情况完全可以建一个联合索引,因为是最左前缀匹配,所以operate_time需要放到最后,而且还需要把其他相关的查询都拿来,需要做一个综合评估。”
“联合索引?最左前缀匹配?综合评估?”工程师不禁陷入了沉思。
多数情况下,我们知道索引能够提高查询效率,但应该如何建立索引?索引的顺序如何?许多人却只知道大概。其实理解这些概念并不难,而且索引的原理远没有想象的那么复杂。

MySQL索引原理
索引目的
索引的目的在于提高查询效率,可以类比字典,如果要查“mysql”这个单词,我们肯定需要定位到m字母,然后从下往下找到y字母,再找到剩下的sql。如果没有索引,那么你可能需要把所有单词看一遍才能找到你想要的,如果我想找到m开头的单词呢?或者ze开头的单词呢?是不是觉得如果没有索引,这个事情根本无法完成?

索引原理
除了词典,生活中随处可见索引的例子,如火车站的车次表、图书的目录等。它们的原理都是一样的,通过不断的缩小想要获得数据的范围来筛选出最终想要的结果,同时把随机的事件变成顺序的事件,也就是我们总是通过同一种查找方式来锁定数据。
数据库也是一样,但显然要复杂许多,因为不仅面临着等值查询,还有范围查询(>、<、between、in)、模糊查询(like)、并集查询(or)等等。数据库应该选择怎么样的方式来应对所有的问题呢?我们回想字典的例子,能不能把数据分成段,然后分段查询呢?最简单的如果1000条数据,1到100分成第一段,101到200分成第二段,201到300分成第三段……这样查第250条数据,只要找第三段就可以了,一下子去除了90%的无效数据。但如果是1千万的记录呢,分成几段比较好?稍有算法基础的同学会想到搜索树,其平均复杂度是lgN,具有不错的查询性能。但这里我们忽略了一个关键的问题,复杂度模型是基于每次相同的操作成本来考虑的,数据库实现比较复杂,数据保存在磁盘上,而为了提高性能,每次又可以把部分数据读入内存来计算,因为我们知道访问磁盘的成本大概是访问内存的十万倍左右,所以简单的搜索树难以满足复杂的应用场景。

磁盘IO与预读
前面提到了访问磁盘,那么这里先简单介绍一下磁盘IO和预读,磁盘读取数据靠的是机械运动,每次读取数据花费的时间可以分为寻道时间、旋转延迟、传输时间三个部分,寻道时间指的是磁臂移动到指定磁道所需要的时间,主流磁盘一般在5ms以下;旋转延迟就是我们经常听说的磁盘转速,比如一个磁盘7200转,表示每分钟能转7200次,也就是说1秒钟能转120次,旋转延迟就是1/120/2 = 4.17ms;传输时间指的是从磁盘读出或将数据写入磁盘的时间,一般在零点几毫秒,相对于前两个时间可以忽略不计。那么访问一次磁盘的时间,即一次磁盘IO的时间约等于5+4.17 = 9ms左右,听起来还挺不错的,但要知道一台500 -MIPS的机器每秒可以执行5亿条指令,因为指令依靠的是电的性质,换句话说执行一次IO的时间可以执行40万条指令,数据库动辄十万百万乃至千万级数据,每次9毫秒的时间,显然是个灾难。下图是计算机硬件延迟的对比图,供大家参考:

考虑到磁盘IO是非常高昂的操作,计算机操作系统做了一些优化,当一次IO时,不光把当前磁盘地址的数据,而是把相邻的数据也都读取到内存缓冲区内,因为局部预读性原理告诉我们,当计算机访问一个地址的数据的时候,与其相邻的数据也会很快被访问到。每一次IO读取的数据我们称之为一页(page)。具体一页有多大数据跟操作系统有关,一般为4k或8k,也就是我们读取一页内的数据时候,实际上才发生了一次IO,这个理论对于索引的数据结构设计非常有帮助。

索引的数据结构
前面讲了生活中索引的例子,索引的基本原理,数据库的复杂性,又讲了操作系统的相关知识,目的就是让大家了解,任何一种数据结构都不是凭空产生的,一定会有它的背景和使用场景,我们现在总结一下,我们需要这种数据结构能够做些什么,其实很简单,那就是:每次查找数据时把磁盘IO次数控制在一个很小的数量级,最好是常数数量级。那么我们就想到如果一个高度可控的多路搜索树是否能满足需求呢?就这样,b+树应运而生。

详解b+树

如上图,是一颗b+树,关于b+树的定义可以参见B+树,这里只说一些重点,浅蓝色的块我们称之为一个磁盘块,可以看到每个磁盘块包含几个数据项(深蓝色所示)和指针(黄色所示),如磁盘块1包含数据项17和35,包含指针P1、P2、P3,P1表示小于17的磁盘块,P2表示在17和35之间的磁盘块,P3表示大于35的磁盘块。真实的数据存在于叶子节点即3、5、9、10、13、15、28、29、36、60、75、79、90、99。非叶子节点只不存储真实的数据,只存储指引搜索方向的数据项,如17、35并不真实存在于数据表中。

b+树的查找过程
如图所示,如果要查找数据项29,那么首先会把磁盘块1由磁盘加载到内存,此时发生一次IO,在内存中用二分查找确定29在17和35之间,锁定磁盘块1的P2指针,内存时间因为非常短(相比磁盘的IO)可以忽略不计,通过磁盘块1的P2指针的磁盘地址把磁盘块3由磁盘加载到内存,发生第二次IO,29在26和30之间,锁定磁盘块3的P2指针,通过指针加载磁盘块8到内存,发生第三次IO,同时内存中做二分查找找到29,结束查询,总计三次IO。真实的情况是,3层的b+树可以表示上百万的数据,如果上百万的数据查找只需要三次IO,性能提高将是巨大的,如果没有索引,每个数据项都要发生一次IO,那么总共需要百万次的IO,显然成本非常非常高。

b+树性质
1.通过上面的分析,我们知道IO次数取决于b+数的高度h,假设当前数据表的数据为N,每个磁盘块的数据项的数量是m,则有h=㏒(m+1)N,当数据量N一定的情况下,m越大,h越小;而m = 磁盘块的大小 / 数据项的大小,磁盘块的大小也就是一个数据页的大小,是固定的,如果数据项占的空间越小,数据项的数量越多,树的高度越低。这就是为什么每个数据项,即索引字段要尽量的小,比如int占4字节,要比bigint8字节少一半。这也是为什么b+树要求把真实的数据放到叶子节点而不是内层节点,一旦放到内层节点,磁盘块的数据项会大幅度下降,导致树增高。当数据项等于1时将会退化成线性表。
2.当b+树的数据项是复合的数据结构,比如(name,age,sex)的时候,b+数是按照从左到右的顺序来建立搜索树的,比如当(张三,20,F)这样的数据来检索的时候,b+树会优先比较name来确定下一步的所搜方向,如果name相同再依次比较age和sex,最后得到检索的数据;但当(20,F)这样的没有name的数据来的时候,b+树就不知道下一步该查哪个节点,因为建立搜索树的时候name就是第一个比较因子,必须要先根据name来搜索才能知道下一步去哪里查询。比如当(张三,F)这样的数据来检索时,b+树可以用name来指定搜索方向,但下一个字段age的缺失,所以只能把名字等于张三的数据都找到,然后再匹配性别是F的数据了, 这个是非常重要的性质,即索引的最左匹配特性。

慢查询优化
关于MySQL索引原理是比较枯燥的东西,大家只需要有一个感性的认识,并不需要理解得非常透彻和深入。我们回头来看看一开始我们说的慢查询,了解完索引原理之后,大家是不是有什么想法呢?先总结一下索引的几大基本原则

建索引的几大原则
1.最左前缀匹配原则,非常重要的原则,mysql会一直向右匹配直到遇到范围查询(>、<、between、like)就停止匹配,比如a = 1 and b = 2 and c > 3 and d = 4 如果建立(a,b,c,d)顺序的索引,d是用不到索引的,如果建立(a,b,d,c)的索引则都可以用到,a,b,d的顺序可以任意调整。
2.=和in可以乱序,比如a = 1 and b = 2 and c = 3 建立(a,b,c)索引可以任意顺序,mysql的查询优化器会帮你优化成索引可以识别的形式
3.尽量选择区分度高的列作为索引,区分度的公式是count(distinct col)/count(*),表示字段不重复的比例,比例越大我们扫描的记录数越少,唯一键的区分度是1,而一些状态、性别字段可能在大数据面前区分度就是0,那可能有人会问,这个比例有什么经验值吗?使用场景不同,这个值也很难确定,一般需要join的字段我们都要求是0.1以上,即平均1条扫描10条记录
4.索引列不能参与计算,保持列“干净”,比如from_unixtime(create_time) = ’2014-05-29’就不能使用到索引,原因很简单,b+树中存的都是数据表中的字段值,但进行检索时,需要把所有元素都应用函数才能比较,显然成本太大。所以语句应该写成create_time = unix_timestamp(’2014-05-29’);
5.尽量的扩展索引,不要新建索引。比如表中已经有a的索引,现在要加(a,b)的索引,那么只需要修改原来的索引即可

回到开始的慢查询
根据最左匹配原则,最开始的sql语句的索引应该是status、operator_id、type、operate_time的联合索引;其中status、operator_id、type的顺序可以颠倒,所以我才会说,把这个表的所有相关查询都找到,会综合分析;
比如还有如下查询

select * from task where status = 0 and type = 12 limit 10;
select count(*) from task where status = 0 ;
那么索引建立成(status,type,operator_id,operate_time)就是非常正确的,因为可以覆盖到所有情况。这个就是利用了索引的最左匹配的原则

查询优化神器 – explain命令
关于explain命令相信大家并不陌生,具体用法和字段含义可以参考官网explain-output,这里需要强调rows是核心指标,绝大部分rows小的语句执行一定很快(有例外,下面会讲到)。所以优化语句基本上都是在优化rows。

慢查询优化基本步骤
0.先运行看看是否真的很慢,注意设置SQL_NO_CACHE
1.where条件单表查,锁定最小返回记录表。这句话的意思是把查询语句的where都应用到表中返回的记录数最小的表开始查起,单表每个字段分别查询,看哪个字段的区分度最高
2.explain查看执行计划,是否与1预期一致(从锁定记录较少的表开始查询)
3.order by limit 形式的sql语句让排序的表优先查
4.了解业务方使用场景
5.加索引时参照建索引的几大原则
6.观察结果,不符合预期继续从0分析

几个慢查询案例
下面几个例子详细解释了如何分析和优化慢查询

复杂语句写法
很多情况下,我们写SQL只是为了实现功能,这只是第一步,不同的语句书写方式对于效率往往有本质的差别,这要求我们对mysql的执行计划和索引原则有非常清楚的认识,请看下面的语句

select
   distinctcert.emp_id
from
   cm_logcl
inner join
   (
      select
         emp.id as emp_id,
         emp_cert.id ascert_id
      from
         employeeemp
      left join
         emp_certificateemp_cert
            onemp.id=emp_cert.emp_id
      where
         emp.is_deleted=0
   )cert
      on (
         cl.ref_table=‘Employee’
         and cl.ref_oid= cert.emp_id
      )
      or (
         cl.ref_table=‘EmpCertificate’
         and cl.ref_oid= cert.cert_id
      )
where
   cl.last_upd_date >=‘2013-11-07 15:03:00’
   and cl.last_upd_date<=‘2013-11-08 16:00:00’;

0.先运行一下,53条记录 1.87秒,又没有用聚合语句,比较慢

53 rows in set (1.87 sec)
1.explain

+—-+————-+————+——-+———————————+———————–+———+——————-+——-+——————————–+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +—-+————-+————+——-+———————————+———————–+———+——————-+——-+——————————–+ | 1 | PRIMARY | cl | range | cm_log_cls_id,idx_last_upd_date | idx_last_upd_date | 8 | NULL | 379 | Using where; Using temporary | | 1 | PRIMARY | <derived2> | ALL | NULL | NULL | NULL | NULL | 63727 | Using where; Using join buffer | | 2 | DERIVED | emp | ALL | NULL | NULL | NULL | NULL | 13317 | Using where | | 2 | DERIVED | emp_cert | ref | emp_certificate_empid | emp_certificate_empid | 4 | meituanorg.emp.id | 1 | Using index | +—-+————-+————+——-+———————————+———————–+———+——————-+——-+——————————–+
简述一下执行计划,首先mysql根据idx_last_upd_date索引扫描cm_log表获得379条记录;然后查表扫描了63727条记录,分为两部分,derived表示构造表,也就是不存在的表,可以简单理解成是一个语句形成的结果集,后面的数字表示语句的ID。derived2表示的是ID = 2的查询构造了虚拟表,并且返回了63727条记录。我们再来看看ID = 2的语句究竟做了写什么返回了这么大量的数据,首先全表扫描employee表13317条记录,然后根据索引emp_certificate_empid关联emp_certificate表,rows = 1表示,每个关联都只锁定了一条记录,效率比较高。获得后,再和cm_log的379条记录根据规则关联。从执行过程上可以看出返回了太多的数据,返回的数据绝大部分cm_log都用不到,因为cm_log只锁定了379条记录。
如何优化呢?可以看到我们在运行完后还是要和cm_log做join,那么我们能不能之前和cm_log做join呢?仔细分析语句不难发现,其基本思想是如果cm_log的ref_table是EmpCertificate就关联emp_certificate表,如果ref_table是Employee就关联employee表,我们完全可以拆成两部分,并用union连接起来,注意这里用union,而不用union all是因为原语句有“distinct”来得到唯一的记录,而union恰好具备了这种功能。如果原语句中没有distinct不需要去重,我们就可以直接使用union all了,因为使用union需要去重的动作,会影响SQL性能。
优化过的语句如下

select
   emp.id
from
   cm_logcl
inner join
   employeeemp
      on cl.ref_table = ‘Employee’
      and cl.ref_oid = emp.id  
where
   cl.last_upd_date >=‘2013-11-07 15:03:00’
   and cl.last_upd_date<=‘2013-11-08 16:00:00’
   and emp.is_deleted = 0  
union
select
   emp.id
from
   cm_logcl
inner join
   emp_certificateec
      on cl.ref_table = ‘EmpCertificate’
      and cl.ref_oid = ec.id  
inner join
   employeeemp
      on emp.id = ec.emp_id  
where
   cl.last_upd_date >=‘2013-11-07 15:03:00’
   and cl.last_upd_date<=‘2013-11-08 16:00:00’
   and emp.is_deleted = 0

4.不需要了解业务场景,只需要改造的语句和改造之前的语句保持结果一致

5.现有索引可以满足,不需要建索引

6.用改造后的语句实验一下,只需要10ms 降低了近200倍!

+—-+————–+————+——–+———————————+——————-+———+———————–+——+————-+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +—-+————–+————+——–+———————————+——————-+———+———————–+——+————-+ | 1 | PRIMARY | cl | range | cm_log_cls_id,idx_last_upd_date | idx_last_upd_date | 8 | NULL | 379 | Using where | | 1 | PRIMARY | emp | eq_ref | PRIMARY | PRIMARY | 4 | meituanorg.cl.ref_oid | 1 | Using where | | 2 | UNION | cl | range | cm_log_cls_id,idx_last_upd_date | idx_last_upd_date | 8 | NULL | 379 | Using where | | 2 | UNION | ec | eq_ref | PRIMARY,emp_certificate_empid | PRIMARY | 4 | meituanorg.cl.ref_oid | 1 | | | 2 | UNION | emp | eq_ref | PRIMARY | PRIMARY | 4 | meituanorg.ec.emp_id | 1 | Using where | | NULL | UNION RESULT | <union1,2> | ALL | NULL | NULL | NULL | NULL | NULL | | +—-+————–+————+——–+———————————+——————-+———+———————–+——+————-+ 53 rows in set (0.01 sec)
明确应用场景
举这个例子的目的在于颠覆我们对列的区分度的认知,一般上我们认为区分度越高的列,越容易锁定更少的记录,但在一些特殊的情况下,这种理论是有局限性的

select * from stage_poi sp where sp.accurate_result=1 and ( sp.sync_status=0 or sp.sync_status=2 or sp.sync_status=4 );
0.先看看运行多长时间,951条数据6.22秒,真的很慢

951 rows in set (6.22 sec)
1.先explain,rows达到了361万,type = ALL表明是全表扫描

+—-+————-+——-+——+—————+——+———+——+———+————-+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +—-+————-+——-+——+—————+——+———+——+———+————-+ | 1 | SIMPLE | sp | ALL | NULL | NULL | NULL | NULL | 3613155 | Using where | +—-+————-+——-+——+—————+——+———+——+———+————-+
2.所有字段都应用查询返回记录数,因为是单表查询 0已经做过了951条

3.让explain的rows 尽量逼近951

看一下accurate_result = 1的记录数

select count(*),accurate_result from stage_poi group by accurate_result; +———-+—————–+ | count(*) | accurate_result | +———-+—————–+ | 1023 | -1 | | 2114655 | 0 | | 972815 | 1 | +———-+—————–+
我们看到accurate_result这个字段的区分度非常低,整个表只有-1,0,1三个值,加上索引也无法锁定特别少量的数据

再看一下sync_status字段的情况

select count(*),sync_status from stage_poi group by sync_status; +———-+————-+ | count(*) | sync_status | +———-+————-+ | 3080 | 0 | | 3085413 | 3 | +———-+————-+
同样的区分度也很低,根据理论,也不适合建立索引

问题分析到这,好像得出了这个表无法优化的结论,两个列的区分度都很低,即便加上索引也只能适应这种情况,很难做普遍性的优化,比如当sync_status 0、3分布的很平均,那么锁定记录也是百万级别的

4.找业务方去沟通,看看使用场景。业务方是这么来使用这个SQL语句的,每隔五分钟会扫描符合条件的数据,处理完成后把sync_status这个字段变成1,五分钟符合条件的记录数并不会太多,1000个左右。了解了业务方的使用场景后,优化这个SQL就变得简单了,因为业务方保证了数据的不平衡,如果加上索引可以过滤掉绝大部分不需要的数据

5.根据建立索引规则,使用如下语句建立索引

alter table stage_poi add index idx_acc_status(accurate_result,sync_status);
6.观察预期结果,发现只需要200ms,快了30多倍。

952 rows in set (0.20 sec)
我们再来回顾一下分析问题的过程,单表查询相对来说比较好优化,大部分时候只需要把where条件里面的字段依照规则加上索引就好,如果只是这种“无脑”优化的话,显然一些区分度非常低的列,不应该加索引的列也会被加上索引,这样会对插入、更新性能造成严重的影响,同时也有可能影响其它的查询语句。所以我们第4步调差SQL的使用场景非常关键,我们只有知道这个业务场景,才能更好地辅助我们更好的分析和优化查询语句。

无法优化的语句
select c.id, c.name, c.position, c.sex, c.phone, c.office_phone, c.feature_info, c.birthday, c.creator_id, c.is_keyperson, c.giveup_reason, c.status, c.data_source, from_unixtime(c.created_time) as created_time, from_unixtime(c.last_modified) as last_modified, c.last_modified_user_id from contact c inner join contact_branch cb on c.id = cb.contact_id inner join branch_user bu on cb.branch_id = bu.branch_id and bu.status in ( 1, 2) inner join org_emp_info oei on oei.data_id = bu.user_id and oei.node_left >= 2875 and oei.node_right <= 10802 and oei.org_category = – 1 order by c.created_time desc limit 0 , 10;
还是几个步骤
0.先看语句运行多长时间,10条记录用了13秒,已经不可忍受

10 rows in set (13.06 sec)
1.explain

+—-+————-+——-+——–+————————————-+————————-+———+————————–+——+———————————————-+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +—-+————-+——-+——–+————————————-+————————-+———+————————–+——+———————————————-+ | 1 | SIMPLE | oei | ref | idx_category_left_right,idx_data_id | idx_category_left_right | 5 | const | 8849 | Using where; Using temporary; Using filesort | | 1 | SIMPLE | bu | ref | PRIMARY,idx_userid_status | idx_userid_status | 4 | meituancrm.oei.data_id | 76 | Using where; Using index | | 1 | SIMPLE | cb | ref | idx_branch_id,idx_contact_branch_id | idx_branch_id | 4 | meituancrm.bu.branch_id | 1 | | | 1 | SIMPLE | c | eq_ref | PRIMARY | PRIMARY | 108 | meituancrm.cb.contact_id | 1 | | +—-+————-+——-+——–+————————————-+————————-+———+————————–+——+———————————————-+
从执行计划上看,mysql先查org_emp_info表扫描8849记录,再用索引idx_userid_status关联branch_user表,再用索引idx_branch_id关联contact_branch表,最后主键关联contact表。
rows返回的都非常少,看不到有什么异常情况。我们在看一下语句,发现后面有order by + limit组合,会不会是排序量太大搞的?于是我们简化SQL,去掉后面的order by 和 limit,看看到底用了多少记录来排序

select count(*) from contact c inner join contact_branch cb on c.id = cb.contact_id inner join branch_user bu on cb.branch_id = bu.branch_id and bu.status in ( 1, 2) inner join org_emp_info oei on oei.data_id = bu.user_id and oei.node_left >= 2875 and oei.node_right <= 10802 and oei.org_category = – 1 +———-+ | count(*) | +———-+ | 778878 | +———-+ 1 row in set (5.19 sec)
发现排序之前居然锁定了778878条记录,如果针对70万的结果集排序,将是灾难性的,怪不得这么慢,那我们能不能换个思路,先根据contact的created_time排序,再来join会不会比较快呢?
于是改造成下面的语句,也可以用straight_join来优化
select
c.id,
c.name,
c.position,
c.sex,
c.phone,
c.office_phone,
c.feature_info,
c.birthday,
c.creator_id,
c.is_keyperson,
c.giveup_reason,
c.status,
c.data_source,
from_unixtime(c.created_time) as created_time,
from_unixtime(c.last_modified) as last_modified,
c.last_modified_user_id
from
contact c
where
exists (
select
1
from
contact_branch cb
inner join
branch_user bu
on cb.branch_id = bu.branch_id
and bu.status in (
1,
2)
inner join
org_emp_info oei
on oei.data_id = bu.user_id
and oei.node_left >= 2875
and oei.node_right <= 10802
and oei.org_category = – 1
where
c.id = cb.contact_id
)
order by
c.created_time desc limit 0 ,
10;

验证一下效果 预计在1ms内,提升了13000多倍! “`sql 10 rows in set (0.00 sec)
本以为至此大工告成,但我们在前面的分析中漏了一个细节,先排序再join和先join再排序理论上开销是一样的,为何提升这么多是因为有一个limit!大致执行过程是:mysql先按索引排序得到前10条记录,然后再去join过滤,当发现不够10条的时候,再次去10条,再次join,这显然在内层join过滤的数据非常多的时候,将是灾难的,极端情况,内层一条数据都找不到,mysql还傻乎乎的每次取10条,几乎遍历了这个数据表!
用不同参数的SQL试验下

select sql_no_cache c.id, c.name, c.position, c.sex, c.phone, c.office_phone, c.feature_info, c.birthday, c.creator_id, c.is_keyperson, c.giveup_reason, c.status, c.data_source, from_unixtime(c.created_time) as created_time, from_unixtime(c.last_modified) as last_modified, c.last_modified_user_id from contact c where exists ( select 1 from contact_branch cb inner join branch_user bu on cb.branch_id = bu.branch_id and bu.status in ( 1, 2) inner join org_emp_info oei on oei.data_id = bu.user_id and oei.node_left >= 2875 and oei.node_right <= 2875 and oei.org_category = – 1 where c.id = cb.contact_id ) order by c.created_time desc limit 0 , 10; Empty set (2 min 18.99 sec)
2 min 18.99 sec!比之前的情况还糟糕很多。由于mysql的nested loop机制,遇到这种情况,基本是无法优化的。这条语句最终也只能交给应用系统去优化自己的逻辑了。
通过这个例子我们可以看到,并不是所有语句都能优化,而往往我们优化时,由于SQL用例回归时落掉一些极端情况,会造成比原来还严重的后果。所以,第一:不要指望所有语句都能通过SQL优化,第二:不要过于自信,只针对具体case来优化,而忽略了更复杂的情况。

慢查询的案例就分析到这儿,以上只是一些比较典型的案例。我们在优化过程中遇到过超过1000行,涉及到16个表join的“垃圾SQL”,也遇到过线上线下数据库差异导致应用直接被慢查询拖死,也遇到过varchar等值比较没有写单引号,还遇到过笛卡尔积查询直接把从库搞死。再多的案例其实也只是一些经验的积累,如果我们熟悉查询优化器、索引的内部原理,那么分析这些案例就变得特别简单了。
写在后面的话
本文以一个慢查询案例引入了MySQL索引原理、优化慢查询的一些方法论;并针对遇到的典型案例做了详细的分析。其实做了这么长时间的语句优化后才发现,任何数据库层面的优化都抵不上应用系统的优化,同样是MySQL,可以用来支撑Google/FaceBook/Taobao应用,但可能连你的个人网站都撑不住。套用最近比较流行的话:“查询容易,优化不易,且写且珍惜!”

参考
参考文献如下:

1.《高性能MySQL》
2.《数据结构与算法分析》

面试总结

Java相关

Java GC机制(重要程度:★★★★★)

主要从三个方面回答:GC是针对什么对象进行回收(可达性分析法),什么时候开始GC(当新生代满了会进行Minor GC,升到老年代的对象大于老年代剩余空间时会进行Major GC),GC做什么(新生代采用复制算法,老年代采用标记-清除或标记-整理算法),感觉回答这些就差不多了,也可以补充一下可以调优的参数(-XX:newRatio,-Xms,-Xmx等等)。详细的可以看我另一篇博客(Java中的垃圾回收机制)。

如何线程安全的使用HashMap(重要程度:★★★★★)

作为Java程序员还是经常和HashMap打交道的,所以HashMap的一些原理还是搞搞清除比较好。这个问题感觉主要就是问HashMap,HashTable,ConcurrentHashMap,sychronizedMap的原理和区别。具体的可以看我另一篇博客(如何线程安全的使用HashMap)。

HashMap是如何解决冲突的(重要程度:★★★★☆)

其实就是链接法,将索引值相同的元素存放到一个单链表里。但为了解决在频繁冲突时HashMap性能降低的问题,Java 8中做了一个小优化,在冲突的元素个数超过设定的值(默认为8)时,会使用平衡树来替代链表存储冲突的元素。具体的可以看我另一篇博客(Java 8中HashMap和LinkedHashMap如何解决冲突)。

Java创建对象有哪几种(重要程度:★★★★☆)

这个问题还算好回答,大概有四种—new、工厂模式、反射和克隆,不过这个问题有可能衍生出关于设计模式,反射,深克隆,浅克隆等一系列问题。。。要做好准备~
参考资料:
设计模式Java版
Java反射详解
深克隆与浅克隆的区别

注解(重要程度:★★★☆☆)

如果简历中有提到过曾自定义过注解,还是了解清楚比较好。主要是了解在自定义注解时需要使用的两个主要的元注解@Retention和@Target。@Retention用来声明注解的保留策略,有CLASS,RUNTIME,SOURCE三种,分别表示注解保存在类文件,JVM运行时刻和源代码中。@Target用来声明注解可以被添加到哪些类型的元素上,如类型,方法和域等。
参考资料:
Java注解

异常(重要程度:★★★☆☆)

一道笔试题,代码如下,问返回值是什么。

1
2
3
4
5
6
7
8
9
10
11
int ret = 0;
try{
throw new Exception();
}
catch(Exception e){
ret = 1;
return ret;
}
finally{
ret = 2;
}

主要的考点就是catch中的return在finally之后执行 但是会将return的值放到一个地方存起来,所以finally中的ret=2会执行,但返回值是1。
参考资料:
深入理解Java异常处理机制
Java异常处理

悲观锁和乐观锁区别,乐观锁适用于什么情况(重要程度:★★★★☆)

悲观锁,就是总觉得有刁民想害朕,每次访问数据的时候都觉得会有别人修改它,所以每次拿数据时都会上锁,确保在自己使用的过程中不会被他人访问。乐观锁就是很单纯,心态好,所以每次拿数据的时候都不会上锁,只是在更新数据的时候去判断该数据是否被别人修改过。
大多数的关系数据库写入操作都是基于悲观锁,缺点在于如果持有锁的客户端运行的很慢,那么等待解锁的客户端被阻塞的时间就越长。Redis的事务是基于乐观锁的机制,不会在执行WATCH命令时对数据进行加锁,只是会在数据已经被其他客户端抢先修改了的情况下,通知执行WATCH命令的客户端。乐观锁适用于读多写少的情况,因为在写操作比较频繁的时候,会不断地retry,从而降低性能。
参考资料:
关于悲观锁和乐观锁的区别
乐观锁和悲观锁

单例模式找错误(重要程度:★★★★☆)

错误是没有将构造函数私有化,单例还是比较简单的,把它的饿汉式和懒汉式的两种实现方式看明白了就可以了。
单例模式

__

Spring相关

关于Spring的问题主要就是围绕着Ioc和AOP,它们真是Spring的核心啊。

Spring Bean的生命周期(重要程度:★★★★★)

就不写我那么low的回答了,直接看参考资料吧。
参考资料:
Spring Bean的生命周期
Top 10 Spring Interview Questions Answers J2EE

Spring中用到的设计模式(重要程度:★★★★★)

工厂模式:IOC容器
代理模式:AOP
策略模式:在spring采取动态代理时,根据代理的类有无实现接口有JDK和CGLIB两种代理方式,就是采用策略模式实现的
单例模式:默认情况下spring中的bean只存在一个实例
只知道这四个。。。。
参考资料:
Design Patterns Used in Java Spring Framework

讲一讲Spring IoC和AOP(重要程度:★★★★★)

IoC的核心是依赖反转,将创建对象和对象之间的依赖管理交给IoC容器来做,完成对象之间的解耦。
AOP主要是利用代理模式,把许多接口都要用的又和接口本身主要的业务逻辑无关的部分抽出来,写成一个切面,单独维护,比如权限验证。这样可以使接口符合“单一职责原则”,只关注主要的业务逻辑,也提高了代码的重用性。

AOP的应用场景(重要程度:★★★★☆)

权限,日志,处理异常,事务等等,个人理解就是把许多接口都要用的又和接口本身主要的业务逻辑无关的部分抽出来,写成一个切面,单独维护,比如权限验证。这样可以使接口符合“单一职责原则”,只关注主要的业务逻辑,也提高了代码的重用性。

Spring中编码统一要如何做(重要程度:★★★☆☆)

配置一个拦截器就行了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<filter> 
       <filter-name>CharacterEncodingFilter</filter-name> 
       <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class
       <init-param> 
           <param-name>encoding</param-name> 
           <param-value>UTF-8</param-value> 
       </init-param> 
       <init-param> 
           <param-name>forceEncoding</param-name> 
           <param-value>true</param-value> 
       </init-param> 
   </filter> 
   <filter-mapping> 
       <filter-name>CharacterEncodingFilter</filter-name> 
       <url-pattern>/*</url-pattern> 
   </filter-mapping>

数据库相关

Mysql索引的内部结构(重要程度:★★★★☆)

B+树,三层,真实的数据存储在叶子节点
参考资料:
MySQL索引原理及慢查询优化

如果一个SQL执行时间比较长怎么办(重要程度:★★★★☆)

可以利用pt-query-digest等工具分析慢查询日志,也可以用explain查看SQL的执行计划。具体可看我的另一篇博客MySQL调优

如果一张表中有上千万条数据应该怎么做分页(重要程度:★★★☆☆)

肯定不能直接limit,offset,主要就是要想办法避免在数据量大时扫描过多的记录。具体可看我的另一篇博客【译】优化MySQL中的分页

什么样的列适合加索引,如果一个列的值只有1和2,那么它适合加索引么(重要程度:★★★☆☆)

  • 在where从句,group by从句,order by从句,on从句中出现的列
  • 索引的字段越小越好
  • 在建立联合索引时,离散度大的列放大联合索引的前面

只有1和2不适合建索引

Redis相关

Redis提供哪几种数据结构(重要程度:★★★★★)

一共有5种,字符串,散列,列表,集合,有序集合。
参考资料:
Redis中文官网

Redis支持集群么,从哪个版本开始支持集群的(重要程度:★★☆☆☆)

支持集群,从3.0版本开始。当然面试时我也没记住版本。。。

Redis集群中,如何将一个对象映射到对应的缓存服务器(重要程度:★★★★☆)

一般就是hash%N,就是用对象的hash值对缓存服务器的个数取余

接上个问题,缓存集群中如果新增一台服务器,怎么才能不影响大部分缓存数据的命中?(重要程度:★★★★☆)

其实就是一致性Hash算法。以前有看过,可惜面试的时候脑袋就空了,只记得一个环,果然还是要实践啊。
参考资料:
Consistent Hashing
五分钟理解一致性哈希算法(consistent hashing)

项目中具体是怎样使用Redis的(重要程度:★★★★☆)

根据实际情况回答吧。。。。我是主要做权限控制时用到了Redis,将用户Id和权限Code拼接在一起作为一个key,放到Redis的集合中,在验证某一用户是否有指定权限时,只需验证集合中是否有用户Id和权限Code拼接的key即可

算法相关

判断一个数字是否为快乐数字(重要程度:★☆☆☆☆)

leetcode第202题
链接

给定一个乱序数组和一个目标数字 找到和为这个数字的两个数字 时间复杂度是多少(重要程度:★☆☆☆☆)

leetcode第一题
链接

如何判断一个链表有没有环(重要程度:★☆☆☆☆)

用快慢指针

删除字符串中的空格 只留一个(重要程度:★☆☆☆☆)

这个比较简单。。。。

二叉树层序遍历(重要程度:★★☆☆☆)

利用队列就可以了

地铁票价是如何计算的(重要程度:★★☆☆☆)

不知道正确答案,感觉是图的最短路径算法相关的。

Elasticsearch相关

为什么要用Elasticsearch(重要程度:★★★★☆)

其实对Es的了解还是比较少的,因为没做多久就去写坑爹代理商了。个人觉得项目中用Es的原因一是可以做分词,二是Es中采用的是倒排索引所以性能比较好,三是Es是个分布式的搜索服务,对各个节点的配置还是很简单方便的

Elasticsearch中的数据来源是什么,如何做同步(重要程度:★★★★☆)

数据是来自其他部门的数据库,会在一开始写python脚本做全量更新,之后利用RabbitMQ做增量更新,就是数据更改之后,数据提供方将更改的数据插入到指定消息队列,由对应的消费者索引到Es中

接上个问题,利用消息队列是会对对方代码造成侵入的,还有没有别的方式(重要程度:★★★☆☆)

还可以读MySQL的binlog

发散思维的题

以下题都是没有正确答案的,不知道是想考思维,还是压力面试,就只写题目,不写回答了。。。

画一下心中房树人的关系(重要程度:★☆☆☆☆)
给你一块地建房如何规划(重要程度:★☆☆☆☆)
估计二号线有几辆车在运行(重要程度:★☆☆☆☆)

其他

Thrift通信协议(重要程度:★★★☆☆)

这个问题被问了两遍,然而现在还是不知道。。。什么东西都不能停留在只会用的阶段啊~

git相关(重要程度:★★★★★)

一些git相关的问题,比如如何做分支管理(git flow),rebase和merge的区别(merge操作会生成一个新的节点)等等。。。

如何学习一门新技术(重要程度:★★★☆☆)

google+官网+stackoverflow+github

比较爱逛的网站和爱看的书(重要程度:★★★☆☆)

根据实际情况回答吧。。。

了不了解微服务(重要程度:★★☆☆☆)

简单了解过。。。
参考资料:
基于微服务的软件架构模式

针对简历中的项目问一些问题(重要程度:★★★★★)

就是根据简历上的项目问一些东西,比如权限控制是怎么做的,有没有碰到过比较难解决的问题之类的,不具体列举了,只要简历上的内容是真实的基本都没啥问题

为什么要离职(重要程度:★★★★★)

被问了n遍,挺不好回答的一个问题,毕竟不算实习期工作还没满一年,这个时候跳槽很容易让人觉得不安稳。。。。

对公司还有什么问题(重要程度:★★★★★)

基本每轮面试结束都会问的一个问题,一开始也没当回事,直到有家公司居然挂在四面的这个问题上,我也是蛮醉的,果然言多必失啊。。。

在***公司最大的收获是什么(重要程度:★★★★★)

对于我来说,觉得最大的收获就是对企业级的应用有了一个大致的了解,企业里的项目不像学校的课程作业,只要jdbc连接数据库完成功能就可以了,企业的项目要考虑很多东西,比如说为了提高可用性,要部署在多台服务器上,用nginx做负载均衡,还有就是用缓存,异步之类来提高接口性能。然后,也是第一次接触到SOA,这种面向服务的架构。也了解到一个好的应用,除了开发本身,一些自动化发布系统和监控系统也是必不可少的。当然,还认识了一群三观合的小伙伴~~~

如何在java中使用ConcurrentHashMap

ConcurrentHashMap(简称CHM)是在Java 1.5作为Hashtable的替代选择新引入的,是concurrent包的重要成员。在Java 1.5之前,如果想要实现一个可以在多线程和并发的程序中安全使用的Map,只能在HashTable和synchronized Map中选择,因为HashMap并不是线程安全的。但再引入了CHM之后,我们有了更好的选择。CHM不但是线程安全的,而且比HashTable和synchronizedMap的性能要好。相对于HashTable和synchronizedMap锁住了整个Map,CHM只锁住部分Map。CHM允许并发的读操作,同时通过同步锁在写操作时保持数据完整性。我们已经在Top 5 Java Concurrent Collections from JDK 5 and 6中学习了CHM的基础知识,在这篇博客中我将介绍以下几点:

  • CHM在Java中如何实现的
  • 什么情况下应该使用CHM
  • 在Java中使用CHM的例子
  • CHM的一些重要特性

Java中ConcurrentHashMap的实现

CHM引入了分割,并提供了HashTable支持的所有的功能。在CHM中,支持多线程对Map做读操作,并且不需要任何的blocking。这得益于CHM将Map分割成了不同的部分,在执行更新操作时只锁住一部分。根据默认的并发级别(concurrency level),Map被分割成16个部分,并且由不同的锁控制。这意味着,同时最多可以有16个写线程操作Map。试想一下,由只能一个线程进入变成同时可由16个写线程同时进入(读线程几乎不受限制),性能的提升是显而易见的。但由于一些更新操作,如put(),remove(),putAll(),clear()只锁住操作的部分,所以在检索操作不能保证返回的是最新的结果。

另一个重要点是在迭代遍历CHM时,keySet返回的iterator是弱一致和fail-safe的,可能不会返回某些最近的改变,并且在遍历过程中,如果已经遍历的数组上的内容变化了,不会抛出ConcurrentModificationExceptoin的异常。

CHM默认的并发级别是16,但可以在创建CHM时通过构造函数改变。毫无疑问,并发级别代表着并发执行更新操作的数目,所以如果只有很少的线程会更新Map,那么建议设置一个低的并发级别。另外,CHM还使用了ReentrantLock来对segments加锁。

Java中ConcurrentHashMap putifAbsent方法的例子

很多时候我们希望在元素不存在时插入元素,我们一般会像下面那样写代码

1
2
3
4
5
6
7
synchronized(map){
  if (map.get(key) == null){
      return map.put(key, value);
  } else{
      return map.get(key);
  }
}

上面这段代码在HashMap和HashTable中是好用的,但在CHM中是有出错的风险的。这是因为CHM在put操作时并没有对整个Map加锁,所以一个线程正在put(k,v)的时候,另一个线程调用get(k)会得到null,这就会造成一个线程put的值会被另一个线程put的值所覆盖。当然,你可以将代码封装到synchronized代码块中,这样虽然线程安全了,但会使你的代码变成了单线程。CHM提供的putIfAbsent(key,value)方法原子性的实现了同样的功能,同时避免了上面的线程竞争的风险。

什么时候使用ConcurrentHashMap

CHM适用于读者数量超过写者时,当写者数量大于等于读者时,CHM的性能是低于Hashtable和synchronized Map的。这是因为当锁住了整个Map时,读操作要等待对同一部分执行写操作的线程结束。CHM适用于做cache,在程序启动时初始化,之后可以被多个请求线程访问。正如Javadoc说明的那样,CHM是HashTable一个很好的替代,但要记住,CHM的比HashTable的同步性稍弱。

总结

现在我们知道了什么是ConcurrentHashMap和什么时候该用ConcurrentHashMap,下面我们来复习一下CHM的一些关键点。

  • CHM允许并发的读和线程安全的更新操作
  • 在执行写操作时,CHM只锁住部分的Map
  • 并发的更新是通过内部根据并发级别将Map分割成小部分实现的
  • 高的并发级别会造成时间和空间的浪费,低的并发级别在写线程多时会引起线程间的竞争
  • CHM的所有操作都是线程安全
  • CHM返回的迭代器是弱一致性,fail-safe并且不会抛出ConcurrentModificationException异常
  • CHM不允许null的键值
  • 可以使用CHM代替HashTable,但要记住CHM不会锁住整个Map

以上就是Java中CHM的实现和使用场景

HashMap的工作原理

HashMap的工作原理是近年来常见的Java面试题。几乎每个Java程序员都知道HashMap,都知道哪里要用HashMap,知道Hashtable和HashMap之间的区别,那么为何这道面试题如此特殊呢?是因为这道题考察的深度很深。这题经常出现在高级或中高级面试中。投资银行更喜欢问这个问题,甚至会要求你实现HashMap来考察你的编程能力。ConcurrentHashMap和其它同步集合的引入让这道题变得更加复杂。让我们开始探索的旅程吧!

先来些简单的问题

“你用过HashMap吗?” “什么是HashMap?你为什么用到它?”

几乎每个人都会回答“是的”,然后回答HashMap的一些特性,譬如HashMap可以接受null键值和值,而Hashtable则不能;HashMap是非synchronized;HashMap很快;以及HashMap储存的是键值对等等。这显示出你已经用过HashMap,而且对它相当的熟悉。但是面试官来个急转直下,从此刻开始问出一些刁钻的问题,关于HashMap的更多基础的细节。面试官可能会问出下面的问题:

“你知道HashMap的工作原理吗?” “你知道HashMap的get()方法的工作原理吗?”

你也许会回答“我没有详查标准的Java API,你可以看看Java源代码或者Open JDK。”“我可以用Google找到答案。”

但一些面试者可能可以给出答案,“HashMap是基于hashing的原理,我们使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象。当我们给put()方法传递键和值时,我们先对键调用hashCode()方法,返回的hashCode用于找到bucket位置来储存Entry对象。”这里关键点在于指出,HashMap是在bucket中储存键对象和值对象,作为Map.Entry。这一点有助于理解获取对象的逻辑。如果你没有意识到这一点,或者错误的认为仅仅只在bucket中存储值的话,你将不会回答如何从HashMap中获取对象的逻辑。这个答案相当的正确,也显示出面试者确实知道hashing以及HashMap的工作原理。但是这仅仅是故事的开始,当面试官加入一些Java程序员每天要碰到的实际场景的时候,错误的答案频现。下个问题可能是关于HashMap中的碰撞探测(collision detection)以及碰撞的解决方法:

“当两个对象的hashcode相同会发生什么?” 从这里开始,真正的困惑开始了,一些面试者会回答因为hashcode相同,所以两个对象是相等的,HashMap将会抛出异常,或者不会存储它们。然后面试官可能会提醒他们有equals()和hashCode()两个方法,并告诉他们两个对象就算hashcode相同,但是它们可能并不相等。一些面试者可能就此放弃,而另外一些还能继续挺进,他们回答“因为hashcode相同,所以它们的bucket位置相同,‘碰撞’会发生。因为HashMap使用链表存储对象,这个Entry(包含有键值对的Map.Entry对象)会存储在链表中。”这个答案非常的合理,虽然有很多种处理碰撞的方法,这种方法是最简单的,也正是HashMap的处理方法。但故事还没有完结,面试官会继续问:

“如果两个键的hashcode相同,你如何获取值对象?” 面试者会回答:当我们调用get()方法,HashMap会使用键对象的hashcode找到bucket位置,然后获取值对象。面试官提醒他如果有两个值对象储存在同一个bucket,他给出答案:将会遍历链表直到找到值对象。面试官会问因为你并没有值对象去比较,你是如何确定确定找到值对象的?除非面试者直到HashMap在链表中存储的是键值对,否则他们不可能回答出这一题。

其中一些记得这个重要知识点的面试者会说,找到bucket位置之后,会调用keys.equals()方法去找到链表中正确的节点,最终找到要找的值对象。完美的答案!

许多情况下,面试者会在这个环节中出错,因为他们混淆了hashCode()和equals()方法。因为在此之前hashCode()屡屡出现,而equals()方法仅仅在获取值对象的时候才出现。一些优秀的开发者会指出使用不可变的、声明作final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生,提高效率。不可变性使得能够缓存不同键的hashcode,这将提高整个获取对象的速度,使用String,Interger这样的wrapper类作为键是非常好的选择。

如果你认为到这里已经完结了,那么听到下面这个问题的时候,你会大吃一惊。“如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?”除非你真正知道HashMap的工作原理,否则你将回答不出这道题。默认的负载因子大小为0.75,也就是说,当一个map填满了75%的bucket时候,和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing,因为它调用hash方法找到新的bucket位置。

如果你能够回答这道问题,下面的问题来了:“你了解重新调整HashMap大小存在什么问题吗?”你可能回答不上来,这时面试官会提醒你当多线程的情况下,可能产生条件竞争(race condition)。

当重新调整HashMap大小的时候,确实存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。这个时候,你可以质问面试官,为什么这么奇怪,要在多线程的环境下使用HashMap呢?:)

热心的读者贡献了更多的关于HashMap的问题:

  1. 为什么String, Interger这样的wrapper类适合作为键? String, Interger这样的wrapper类作为HashMap的键是再适合不过了,而且String最为常用。因为String是不可变的,也是final的,而且已经重写了equals()和hashCode()方法了。其他的wrapper类也有这个特点。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。不可变性还有其他的优点如线程安全。如果你可以仅仅通过将某个field声明成final就能保证hashCode是不变的,那么请这么做吧。因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这样就能提高HashMap的性能。
  2. 我们可以使用自定义的对象作为键吗? 这是前一个问题的延伸。当然你可能使用任何对象作为键,只要它遵守了equals()和hashCode()方法的定义规则,并且当对象插入到Map中之后将不会再改变了。如果这个自定义对象时不可变的,那么它已经满足了作为键的条件,因为当它创建之后就已经不能改变了。
  3. 我们可以使用CocurrentHashMap来代替Hashtable吗?这是另外一个很热门的面试题,因为ConcurrentHashMap越来越多人用了。我们知道Hashtable是synchronized的,但是ConcurrentHashMap同步性能更好,因为它仅仅根据同步级别对map的一部分进行上锁。ConcurrentHashMap当然可以代替HashTable,但是HashTable提供更强的线程安全性。看看这篇博客查看Hashtable和ConcurrentHashMap的区别。

我个人很喜欢这个问题,因为这个问题的深度和广度,也不直接的涉及到不同的概念。让我们再来看看这些问题设计哪些知识点:

  • hashing的概念
  • HashMap中解决碰撞的方法
  • equals()和hashCode()的应用,以及它们在HashMap中的重要性
  • 不可变对象的好处
  • HashMap多线程的条件竞争
  • 重新调整HashMap的大小

总结

HashMap的工作原理

HashMap基于hashing原理,我们通过put()和get()方法储存和获取对象。当我们将键值对传递给put()方法时,它调用键对象的hashCode()方法来计算hashcode,让后找到bucket位置来储存值对象。当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。HashMap使用链表来解决碰撞问题,当发生碰撞了,对象将会储存在链表的下一个节点中。 HashMap在每个链表节点中储存键值对对象。

当两个不同的键对象的hashcode相同时会发生什么? 它们会储存在同一个bucket位置的链表中。键对象的equals()方法用来找到键值对。

因为HashMap的好处非常多,我曾经在电子商务的应用中使用HashMap作为缓存。因为金融领域非常多的运用Java,也出于性能的考虑,我们会经常用到HashMap和ConcurrentHashMap。你可以查看更多的关于HashMap的文章:

原文链接: Javarevisited 翻译: ImportNew.com 唐小娟
译文链接: http://www.importnew.com/7099.html