帮企业建设网站和维护网站开发合作意向协议书
帮企业建设网站和维护,网站开发合作意向协议书,wordpress导航标签,企业品牌网站建设注意事项1. JVM内存区域与模型#xff1a;从“堆栈”到“元空间”的演进
很多朋友一提到JVM#xff0c;第一反应就是“堆”和“栈”。这没错#xff0c;但JVM的内存世界远比这复杂。我刚开始学Java那会儿#xff0c;也以为内存就这两块#xff0c;后来在线上排查一个内存溢出问题时…1. JVM内存区域与模型从“堆栈”到“元空间”的演进很多朋友一提到JVM第一反应就是“堆”和“栈”。这没错但JVM的内存世界远比这复杂。我刚开始学Java那会儿也以为内存就这两块后来在线上排查一个内存溢出问题时才发现自己太天真了。当时应用频繁Full GC我盯着堆内存监控图看了半天死活找不到问题最后才发现是元空间Metaspace爆了。所以理解JVM内存的完整布局是解决一切性能问题的起点。JVM运行时数据区主要分为线程私有和线程共享两大类。线程私有的包括程序计数器PC Register、Java虚拟机栈Java Stack和本地方法栈Native Method Stack。程序计数器是当前线程所执行的字节码的行号指示器它是唯一一个在JVM规范中没有规定任何OutOfMemoryError情况的区域。每个线程都有自己的虚拟机栈用于存储栈帧Stack Frame。每次方法调用都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接和方法出口等信息。本地方法栈则为JVM使用到的Native方法服务。线程共享的区域才是我们通常关注的重点包括堆Heap和方法区Method Area。堆是JVM管理的最大一块内存几乎所有对象实例和数组都在这里分配内存。方法区则用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。这里有个关键变化点在JDK 8之前方法区有一个叫“永久代PermGen”的实现。很多朋友应该都见过java.lang.OutOfMemoryError: PermGen space这个错误。永久代的大小是固定的通过-XX:MaxPermSize设置一旦加载的类太多或者常量池过大就容易溢出。而且永久代的垃圾回收效率很低Full GC时才会触发经常成为性能瓶颈。所以从JDK 8开始HotSpot JVM彻底移除了永久代引入了元空间Metaspace。元空间不再使用JVM的堆内存而是使用本地内存Native Memory。这意味着只要你的操作系统内存足够大理论上元空间可以无限扩展当然你可以用-XX:MaxMetaspaceSize来限制它。这个改动解决了永久代容易溢出的问题也使得类的元数据管理更加灵活。但这也带来了新的问题如果元空间无限增长可能会吃光你的系统内存。我就遇到过因为反射、动态代理生成大量类导致元空间暴涨最终把服务器物理内存耗尽的案例。所以监控Metaspace的使用情况在JDK 8的环境中变得同样重要。2. 垃圾回收核心算法不只是“标记”与“清除”说到垃圾回收GC很多人第一反应就是“标记-清除”。这确实是基础但现代JVM的GC算法要精巧得多。理解这些算法你才能明白为什么会有那么多不同的垃圾收集器以及如何为你的应用选择合适的那个。最基础的标记-清除Mark-Sweep算法分为两步首先标记出所有需要回收的对象然后统一回收。它的主要问题是会产生大量不连续的内存碎片。想象一下你的房间东西扔得到处都是虽然总空间够但想放个大柜子却找不到一块完整的空地。这就是内存碎片化会导致后续分配大对象时可能触发另一次不必要的GC。为了解决碎片问题复制Copying算法出现了。它把内存分成大小相等的两块每次只用一块。当这一块用完了就把还存活的对象复制到另一块上然后把已使用的内存一次性清理掉。这个算法简单高效没有碎片但代价是可用内存缩小了一半。所以它非常适合对象“朝生夕死”的新生代Young Generation。实际上HotSpot虚拟机默认将新生代划分为一个较大的Eden空间和两个较小的Survivor空间比例通常是8:1:1。每次Minor GC时将Eden和一个Survivor中存活的对象复制到另一个Survivor中然后清空Eden和那个已用的Survivor。如果Survivor空间不够存放存活对象这些对象会通过**分配担保Handle Promotion**机制直接进入老年代。对于对象存活率高的老年代Old Generation复制算法就不划算了复制大量存活对象成本高。所以老年代一般使用标记-整理Mark-Compact算法。它在标记完成后不是直接清理而是让所有存活的对象都向内存一端移动然后直接清理掉边界以外的内存。这样既避免了碎片又不像复制算法那样浪费空间。但移动对象是个重操作所以老年代的GCMajor GC/Full GC通常比新生代的GCMinor GC要慢得多。现代商用JVM的垃圾收集都采用分代收集Generational Collection算法。这基于一个经验法则绝大多数对象都是朝生夕死的。所以我们把堆分为新生代和老年代。新生代用复制算法因为每次回收都有大量对象死去复制少量存活对象的成本很低。老年代用标记-清除或标记-整理算法因为那里对象存活率高没有额外空间做分配担保。这种“分而治之”的策略大大提升了GC效率。3. 七大垃圾收集器从Serial到ZGC的抉择知道了算法我们来看看实现这些算法的“工人”——垃圾收集器。没有最好的收集器只有最适合你场景的。选错了性能可能天差地别。Serial收集器是最古老、最基本的。它是一个单线程收集器在进行垃圾回收时必须暂停所有其他工作线程Stop The World。听起来很糟糕对吧但在客户端模式或单核CPU、内存很小的嵌入式设备上它没有线程交互开销简单而高效。对于很多小型应用它反而是不错的选择。ParNew收集器本质上是Serial收集器的多线程并行版本。除了使用多线程进行垃圾收集外其他行为与Serial完全一样。它是许多运行在服务端模式下的JVM中新生代的默认收集器在JDK 7及之前一个重要原因是它能与CMS收集器配合工作。Parallel Scavenge收集器也是一个用于新生代的多线程收集器但它关注的目标不同。CMS等收集器的目标是尽可能缩短垃圾收集时用户线程的停顿时间低延迟而Parallel Scavenge的目标是达到一个可控制的吞吐量Throughput。吞吐量 运行用户代码时间 / (运行用户代码时间 垃圾收集时间)。高吞吐量可以最高效率地利用CPU时间尽快完成程序的运算任务适合后台运算、科学计算等不需要太多交互的场景。它提供了两个参数用于精确控制吞吐量-XX:MaxGCPauseMillis最大GC停顿时间和-XX:GCTimeRatioGC时间占总时间的比率。Serial Old和Parallel Old分别是Serial和Parallel Scavenge的老年代版本。Serial Old使用“标记-整理”算法Parallel Old则使用多线程的“标记-整理”算法。CMSConcurrent Mark Sweep收集器是一款以获取最短回收停顿时间为目标的收集器非常适合互联网站或B/S系统的服务端重视服务的响应速度。它的运作过程相对复杂分为四个步骤初始标记STW、并发标记、重新标记STW和并发清除。其中耗时最长的并发标记和并发清除阶段都可以与用户线程一起工作所以总体上停顿时间较短。但CMS有三个明显的缺点1对CPU资源非常敏感因为并发阶段会占用一部分线程导致应用变慢2无法处理“浮动垃圾”可能在并发清理阶段产生新的垃圾只能留到下次GC3因为是“标记-清除”算法会产生大量空间碎片可能导致Full GC提前触发。G1Garbage-First收集器是JDK 7 Update 4之后引入的服务端收集器目标是替代CMS。它不再坚持固定大小和固定数量的分代区域划分而是将整个Java堆划分为多个大小相等的独立区域Region。G1跟踪各个Region里面的垃圾堆积“价值”大小在后台维护一个优先列表每次根据允许的收集时间优先回收价值最大的Region名称由来。它兼具并行与并发、分代收集、空间整合整体看是标记-整理局部是复制算法、可预测停顿等优点。从JDK 9开始G1成为了服务端模式的默认垃圾收集器。ZGCZ Garbage Collector是JDK 11中引入的一款新的低延迟垃圾收集器实验性在JDK 15中转为正式特性。它的目标是在任意堆内存大小下将停顿时间控制在10毫秒以内。ZGC通过染色指针Colored Pointers和读屏障Load Barriers等技术实现了几乎所有垃圾收集阶段都与应用线程并发执行停顿时间几乎与堆大小无关。对于需要极低延迟如金融交易、实时游戏的应用ZGC是值得深入研究的选项。4. 类加载机制与双亲委派Java安全的基石我们写的.java文件编译成.class字节码后是如何变成JVM中一个可用的类的这就涉及到类加载Class Loading。这个过程分为加载Loading、链接Linking、初始化Initialization三个阶段。加载阶段JVM需要完成三件事1通过一个类的全限定名来获取定义此类的二进制字节流2将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构3在内存中生成一个代表这个类的java.lang.Class对象作为方法区这个类的各种数据的访问入口。这个阶段我们可以通过自定义类加载器如从网络、数据库加载字节码来干预。链接又分为验证Verification、准备Preparation、解析Resolution。验证确保Class文件的字节流符合JVM规范不会危害虚拟机安全。准备阶段为**类变量static变量**分配内存并设置初始零值如0、false、null。解析是将常量池内的符号引用替换为直接引用的过程。初始化是执行类构造器clinit()方法的过程。这个方法是由编译器自动收集类中所有类变量的赋值动作和**静态语句块static{}块**中的语句合并产生的。虚拟机会保证在子类的clinit()方法执行前父类的clinit()方法已经执行完毕。这里最核心的机制是双亲委派模型Parent Delegation Model。这不是继承关系而是一种组合关系。JVM内置了三个类加载器启动类加载器Bootstrap ClassLoader用C实现负责加载JAVA_HOME/lib下的核心类库扩展类加载器Extension ClassLoader负责加载JAVA_HOME/lib/ext目录下的类应用程序类加载器Application ClassLoader也叫系统类加载器负责加载用户类路径ClassPath上的类库。我们还可以自定义类加载器。双亲委派的工作流程是当一个类加载器收到加载请求时它首先不会自己去加载而是把这个请求委派给父类加载器去完成。每一层都是如此因此所有的加载请求最终都应该传送到顶层的启动类加载器。只有当父加载器反馈自己无法完成这个加载请求它的搜索范围中没有找到所需的类时子加载器才会尝试自己去加载。这样做的好处有两个一是确保类的全局唯一性。比如java.lang.Object无论哪个加载器要加载这个类最终都会委派给启动类加载器去加载这样就保证了在程序中这个核心类只有一份。二是安全。防止用户自定义一个恶意的java.lang.String类来替换核心API。因为根据委派机制会优先由启动类加载器加载核心的String类自定义的类不会被加载。我见过有团队为了热部署破坏了双亲委派结果导致了诡异的类冲突问题排查了整整两天。所以除非你非常清楚自己在做什么否则不要轻易破坏这个机制。5. 内存分配与回收策略对象的一生一个对象在JVM中是如何“诞生”、如何“成长”、最终又如何“消亡”的理解这个过程对写出内存友好的代码至关重要。对象的创建通常从一条new指令开始。JVM首先检查这个指令的参数能否在常量池中定位到一个类的符号引用并检查这个类是否已被加载、解析和初始化过。如果没有先执行类加载过程。类加载检查通过后JVM将为新生对象分配内存。对象所需内存大小在类加载完成后便可完全确定。分配方式有两种如果Java堆内存是规整的用过的内存在一边空闲的在另一边采用指针碰撞Bump the Pointer就是把分界点的指针向空闲空间那边挪动一段与对象大小相等的距离。如果内存不规整已使用和空闲内存相互交错JVM就必须维护一个空闲列表Free List记录哪些内存块是可用的分配时从列表中找到一块足够大的空间划分给对象。在并发环境下分配内存不是线程安全的。解决这个问题有两种方案一是采用CASCompare And Swap配上失败重试的方式保证更新操作的原子性二是TLABThread Local Allocation Buffer即每个线程在堆中预先分配一小块私有内存对象优先在TLAB中分配避免了同步开销。可以通过-XX:/-UseTLAB参数来设定。内存分配完成后JVM需要将分配到的内存空间不包括对象头都初始化为零值。这保证了对象的实例字段在Java代码中可以不赋初始值就直接使用程序能访问到这些字段的数据类型所对应的零值。接下来是设置对象头Object Header。对象头包含两部分信息第一部分是Mark Word用于存储对象自身的运行时数据如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等。第二部分是类型指针即对象指向它的类元数据的指针JVM通过这个指针来确定这个对象是哪个类的实例。如果是数组对象头还会有一块用于记录数组长度的数据。从JVM视角看一个新对象已经产生了。但从程序视角看对象创建才刚刚开始——执行init方法即构造器按照程序员的意愿初始化对象为属性赋真正的值。对象的内存分配首先会尝试在栈上分配Stack Allocation。如果通过逃逸分析Escape Analysis发现一个对象的作用域没有逃逸出方法外即不会被外部方法引用那么这个对象就可能被优化为栈上分配。栈上分配的对象随栈帧出栈而销毁无需GC介入效率极高。对于逃逸出方法但仅限于线程内的对象JIT编译器还可能进行标量替换Scalar Replacement将这个对象拆散将其成员变量分解为若干个独立的局部变量。如果无法在栈上分配对象会在Eden区分配。当Eden区没有足够空间时会触发一次Minor GC。在Minor GC中Eden区存活的对象会被复制到Survivor区通常是To Survivor并且年龄加1。以后每次Minor GC存活的对象会在两个Survivor区之间来回拷贝每拷贝一次年龄加1。当对象的年龄达到一定阈值默认15可通过-XX:MaxTenuringThreshold设置就会被晋升到老年代。也有一些特殊情况会直接进入老年代1大对象比如很长的字符串或数组可以通过-XX:PretenureSizeThreshold参数设置直接进入老年代的阈值避免在Eden和Survivor区之间来回复制。2动态对象年龄判定如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半年龄大于或等于该年龄的对象就可以直接进入老年代。当老年代空间不足时会触发Major GC通常伴随至少一次Minor GC也常被称为Full GC。Full GC会对整个堆包括新生代、老年代以及方法区元空间进行回收停顿时间最长应尽量避免其频繁发生。6. 实战调优与故障排查从参数到工具理论懂了最终还是要落地。JVM调优没有银弹核心是监控、分析、假设、验证的循环。我分享几个实战中踩过的坑和总结的经验。首先你得知道看什么。JVM监控工具是我们的眼睛。命令行工具如jps查看JVM进程、jstat查看类加载、GC、JIT编译等统计信息、jmap生成堆转储快照、jstack生成线程快照是基本功。可视化工具我推荐JVisualVMJDK自带和MATMemory Analyzer Tool。JVisualVM可以实时监控CPU、内存、类、线程还能做抽样和快照对比。MAT则是分析堆转储文件、定位内存泄漏的神器。其次关键的JVM启动参数必须心中有数。堆内存相关-Xms初始堆大小、-Xmx最大堆大小。我一般设置成一样避免堆动态调整带来的性能波动。-Xmn新生代大小这个值需要权衡太大会导致老年代变小容易触发Full GC太小则导致Minor GC频繁。老年代大小就是-Xmx减去-Xmn。新生代内部比例-XX:SurvivorRatio8表示Eden和Survivor的比例是8:1:1。-XX:MaxTenuringThreshold15设置晋升老年代的年龄阈值。元空间-XX:MetaspaceSize和-XX:MaxMetaspaceSize。我建议把初始值设得比默认值约20M大一些比如256M避免运行时频繁扩容。GC日志是排查问题的金矿。务必加上-XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:path。分析GC日志你要关注GC频率Minor GC和Full GC多久发生一次、GC耗时每次暂停多长时间、吞吐量GC时间占应用运行时间的比例、晋升速率对象从新生代进入老年代的速度。如果发现Full GC频繁通常意味着内存泄漏或老年代空间设置过小。一个常见的内存泄漏场景是静态集合类持有对象引用。比如用一个static Map做缓存却忘了设置过期或清理策略对象只进不出最终撑爆老年代。排查这类问题可以用jmap -dump:live,formatb,fileheap.hprof pid导出堆转储文件然后用MAT打开查看Dominator Tree和Histogram找到那些占用内存最大的对象和类顺着引用链Path to GC Roots就能找到“罪魁祸首”。另一个坑是线程局部变量ThreadLocal使用不当。ThreadLocal的key是弱引用但value是强引用。如果线程池中的线程一直存活比如Tomcat的工作线程而ThreadLocal使用后没有调用remove()方法那么value会一直存在一条强引用链Thread - ThreadLocalMap - Entry - Value导致无法回收造成内存泄漏。所以使用ThreadLocal时一定要在finally块中调用remove()。对于栈溢出StackOverflowError通常是方法递归调用层次太深或者线程栈空间太小通过-Xss设置。而堆溢出OutOfMemoryError: Java heap space除了内存泄漏也可能是内存设置过小或者存在一次性加载大量数据比如从数据库一次查询几十万条记录的情况。这时候需要结合业务代码和内存分析工具来定位。最后调优是一个平衡的艺术。追求低延迟用CMS或G1就可能牺牲一些吞吐量追求高吞吐量用Parallel Scavenge停顿时间就可能变长。你需要根据你的应用类型Web服务、批处理、实时计算和SLA服务等级协议来制定调优目标。没有监控的调优就是盲人摸象所以先把监控体系建起来让数据告诉你问题在哪里。