秃顶程序员与你谈谈如何进行 Java 系统性能优化

引言(文章过长但干货满满,请更换平时你认为最帅的姿势阅读完本文)

系统性能优化涉及面非常广,涵盖方案优化、编码优化、并发优化、 JVM 调优等诸多方面的知识。

虽然不同系统的优化策略存在差异,但从全局来看,它们的共性仍是主要的。首先,我们可以从方案设计、编码、并发设计、JVM 等方面去优化我们的系统;然后,可以通过一些 Linux 系统命令和工具去发现系统的性能瓶颈;最后,结合业务特点采用缓存、异步化、并发等方式对系统进行“定制”优化。

本篇文章主要内容:

        1.评估系统性能的指标、Amdahl 定理、系统优化路线;

        2.识别 Java 应用性能瓶颈的方法与工具;

        3.系统优化之方案设计优化、编码优化、并发设计优化、.JVM 调优、缓存设计等。

预备知识

关于系统性能,经过多年的发展已经形成了一个包含系列指标的评价体系,本节将对其进行简要介绍。此外,本节还将简述系统优化的步骤和测试验证方法。

评估系统性能的参考指标

资源指标

CPU 使用率: 指用户进程与系统进程消耗的 CPU 时间百分比,长时间持续运行的情况下,一般可接受上限不超过 85%;

内存利用率: 用于评估程序在运行时占用的内存空间,计算公式为:内存利用率 =(1-空闲内存/总内存大小)* 100%,一般内存使用率可接受上限为 85%;

磁盘 I/O: 磁盘主要用于存取数据,因此 IO 操作也有读、写之分,存数据的时候对应的是写 IO 操作,取数据的时候对应的是读 IO 操作,一般使用“%Disk-Time”(磁盘用于读写操作所占用的时间百分比)度量磁盘读写性能。

网络带宽: 一般使用计数器“Bytes-Total/sec”来度量,“Bytes-Total/sec”表示为发送和接收字节的速率,包括帧字符在内。判断网络连接速度是否是瓶颈,可以用该计数器的值和目前网络的带宽比较。

系统指标

执行时间: 一段代码从开始执行到运行结束所需要的时间。

响应时间: 对请求作出响应所需要的时间,一般包括网络传输时间、应用服务器处理时间、数据库服务器处理时间。

吞吐量: 指单位时间内系统处理用户的请求数。从业务角度看,吞吐量可以用:请求数/秒、页面数/秒、人数/天或处理业务数/小时等单位来衡量;从网络角度看,吞吐量可以用:字节/秒来衡量。

并发用户数: 并发数用于衡量软件系统的并发处理能力,和吞吐量不同,它大多是占用套接字、句柄等操作系统资源。

原理定律了解

木桶原理

大意:一只木桶盛水的多少,并不取决于桶壁上最高的那块木块,而是取决于桶壁上最短的那块。 木桶原理应用到软件系统中可以这样理解:对于一个软件系统,影响其性能的因素并不唯一,常见因素有内存资源、 CPU 资源、磁盘 I/O 等,即使系统拥有充足的内存和 CPU 资源,但如果磁盘 IO 性能低下,那么系统性能总体上还是不高。

阿姆达尔(Amdahl)定律

阿姆达尔定律是计算机系统设计的重要定量原理之一,于 1967 年由 IBM360 系列机的主要设计者 Amdahl 首先提出。该定律是指:系统中对某一部件采用更快执行方式所能获得的系统性能改进程度,取决于这种执行方式被使用的频率,或所占总执行时间的比例。阿姆达尔定律实际上定义了采取增强(加速)某部分功能处理的措施后可获得的性能改进或执行时间的加速比。

webp

加速比越大,表明系统优化效果越明显。关于加速比的计算,在计算机领域还有另外一个公式,如下:

webp

参数 F(系统内必须串行化的比重)相对而言比较难理解,在此,我举一个例子加以说明。如下所示,一个程序(计算机应用)包含 5 个步骤,每个步骤需耗时 100ms,其中步骤 2、步骤 4 可以并行,其它步骤只能串行。

webp

根据阿姆达尔定律,该系统的串行化的比重:F=3/5=0.6

由于该系统中,步骤 2、步骤 4 可以并行执行,因此,如果增加 CPU 的数量,以并行替代串行,便可以减少耗时,如下所示:

webp

经过并行优化后,加速比 = 500ms/400ms = 1.25,不难想见,如果 CPU 的数量无限多,那么步骤 2、步骤 4 的处理耗时将逼近 0,加速比的极限为:500ms/300ms = 1.67。

当然,加速比也可以通过上面的公式计算得出,如下所示:

webp

优化步骤

在实际应用中,系统的优化大致可以分为以下几个步骤:

        ·     确定优化目标;

        ·     测试系统是否满足目标;

        ·     如果不满足,则排查系统瓶颈所在;

        ·     对系统瓶颈进行优化,继续 2、3 步骤,直到达到目标。

webp

需要强调的是,优化不能盲目进行,一定要在代码可读性、可扩展性和系统性能之间做出权衡。 同时,坊间有言——"过早的优化是另一种罪恶的来源",优化应该由真实业务场景来驱动,而不是无病呻吟,为优化而优化。

测试验证

在系统优化过程中,为了判定系统是否已经达到目标要求,需要对系统进行压力测试。 当前很多工具可以帮助我们进行系统压测,比如 Loadrunner、Jmeter 等优秀的测试工具。

压测指标: CPU 使用率、JVM 堆栈使用情况、GC/FGC 次数、Load 指标、网络延时等。重点关注以下指标:

        ·     CPU 和 Load 值;

        ·     要求满足 CPU <= 85%;

        ·     Load < CPU 数 * 1.2 的情况下系统的 TPS 和 QPS 满足业务要求。

识别系统性能瓶颈

性能瓶颈实际上就是一个软件系统的性能缺陷,也是我们要优化的点,那么,如何识别一个系统的性能瓶颈呢?本节将介绍几种常见的方法。

通过命令识别性能瓶颈

Linux 系统命令

top top 命令是 Linux 下常用的性能分析工具,能够实时显示系统 CPU、内存、Load、进程等信息,类似于 Windows 的任务管理器。

sar sar(system activity reporter)是目前 Linux 上最为全面的系统性能分析工具之一,可以从多方面对系统的活动进行报告,包括:文件的读写情况、系统调用的情况、磁盘 I/O、CPU 效率、内存使用状况、进程活动及IPC有关的活动等。

例子:sar -u 1 10 每秒采集一次 CPU 信息,共采集 10 次。

vmstat vmstat(virtual meomory statistics)命令可对操作系统的虚拟内存、进程、CPU 活动进行监控。它是对系统的整体情况进行统计,不足之处是无法对某个进程进行深入分析。vmstat 工具提供了一种低开销的系统性能观察方式,即便在高负荷的服务器上也能轻松使用。

例子:vmstat 1 10 每秒采集信息,共采集 10 次。

iostat iostat 命令用于报告 CPU 统计信息和整个系统、磁盘和 CD-ROM 的输入/输出统计信息。根据 iostat 命令产生的报告,用户可确定一个系统配置是否平衡,并据此在物理磁盘与适配器之间更好地平衡输入/输出负载。iostat 工具的主要目的是通过监控磁盘的利用率,而探测到系统中的 I/O 瓶颈。 例子:iostat 1 10 每秒采集信息,共采集 10 次。

pidstat pidstat 是 sysstat 工具的一个命令,主要用于监控全部或指定进程占用系统资源的情况,如 CPU、内存、设备 IO、任务切换、线程等。

例子:pidstat -p 1187 1 3 -u -t 采集指定进程的 CPU 信息

dstat dstat 命令是一个用来替换 vmstat、iostat、netstat、nfsstat 和 ifstat 这些命令的工具,是一个全能系统信息统计工具,可以实时的监控 CPU、磁盘、网络、IO、内存等使用情况。 例子:dstat -clm 实时监控系统 CPU、Load、内存信息。压测过程中经常使用该命令进行系统监控。

JDK 命令

JDK 提供了很多内置的小工具,这里介绍几种使用频率最高的工具。

jps jps(Java Virtual Machine Process Status Tool)是从 JDK1.5 开始提供的一个显示当前所有 Java 进程 pid 的命令,简单实用,非常适合在 linux/unix 平台上简单察看当前 Java 进程的一些简单情况。

例子:jps -m -l -v 用于输出 Java 进程的 pid, 进程参数,函数完整路径,虚拟机配置参数。

jstat jstat(Java Virtual Machine Statistics Minitoring Tool)用于观察 Java 应用程序运行时的信息工具。该命令有众多选项,可以用来统计加载的类信息、GC 信息、堆内存分配信息等。

例子:jstat -gcutil 2972。

jmap jmap(Java Memory Map)主要用于打印指定 Java 进程(应用程序)的堆快照和对象的统计信息。

例子:jmap -histo 2927 > a.txt 统计 PID 为 2927 的 Java 程序的对象统计信息。 例子:jmap -dump:format=b,file=a.txt 统计 PID 为 2927 的 Java 程序的当前堆快照信息。

jhat jhat(Java Head Analyse Tool)主要是用来分析 Java 堆的命令,可以将堆中的对象以 HTML 的形式显示出来,包括对象的数量,大小等等,并支持对象查询语言。

例子:jhat heap.hprof

jstack jstack 工具可以用来查看 Java 进程里的线程信息,根据这些线程堆栈信息,可以去检查 Java 程序出现的问题,如:检测死锁并输出死锁的信息。

例子:jstack -l 2348 > a.txt

通过可视化工具识别性能瓶颈

JConsole 从 Java5 开始引入了 JConsole。JConsole 是一个内置 Java 性能分析器,可以从命令行或在 GUI shell 中运行。可以轻松地使用 JConsole 来监控 Java 应用程序性能(内存,CPU,线程的使用情况等)和跟踪 Java 中的代码。作为 JDK 自带的监控工具,操作简便,比较容易上手。

Virtual-VM VisualVM 是一款免费的,集成了多个 JDK 命令行工具(包括 jstat、jamp、jhat、jstack等)的可视化工具,它能提供强大的分析能力,对 Java 应用程序做性能分析和调优。这些功能包括生成和分析海量数据、跟踪内存泄漏、监控垃圾回收器、执行内存和 CPU 分析,同时它还支持在 MBeans 上进行浏览和操作。

系统优化-方案设计优化

在设计方案时,合理的采用 Java 设计模式和常用的优化思想(如池化对象、并行代替串行等)常常能起到事半功倍的效果。

善用设计模式

设计模式(Design pattern)代表了最佳的实践,它是软件开发人员在软件开发过程中面临的一般问题的解决方案。这些解决方案是众多软件开发人员经过相当长的一段时间的试验和错误总结出来的。目前,公认的 Java 设计模式有 23 种,其中常用的有:单例模式、工厂模式、适配器模式、装饰器模式、代理模式、外观模式、桥接模式、组合模式、享元模式、策略模式、模板方法模式、观察者模式。设计模式涉及的内容非常多,不便展开,在此仅简单介绍几种。

单例模式 单例模式(Singleton Pattern)是 Java 中最简单的设计模式之一。保证一个类仅有一个实例,并提供一访问它的全局访问点。对于频繁使用的对象(特别是重量级的对象),采用单例模式可以省略创建对象所消耗的时间。由于减少了 new 操作的次数,因而对系统内存的使用频率降低。这将减轻 GC 的压力,缩短 GC 停顿的时间。

代理模式 在代理模式(Proxy Pattern)中,一个类代表另一个类的功能,可为其它对象提供一种代理以控制对这个对象的访问。代理模式在我们的日常开发中使用比较频繁,比如为了安全原因需要屏蔽客户端直接访问真实对象;或者远程调用中需要使用代理类处理远程方法调用的技术细节;也可能是为了提升系统性能,对真实对象进行封装,实现延迟加载从而提升系统性能。代理分为静态代理和动态代理两种,cglib 框架实现动态代理。

装饰器模式 装饰器模式(Decorator Pattern)允许向一个现有的对象添加新的功能,同时又不改变其结构。一般地,为了扩展一个类经常使用继承方式实现,由于继承为类引入静态特征,并且随着扩展功能的增多,子类会很膨胀,继承是紧密耦合的,不利于后续的维护。装饰器模式下,装饰类和被装饰类可以独立发展,不会相互耦合,装饰器模式是继承的一个替代模式,它可以动态扩展一个实现类的功能。典型的例子就是 JDK 的 InputStream 和 OutStream,通过 BufferedInputStream 和 BufferedOutStream 的包装实现性能的提升。

观察者模式 观察者模式(Observer Pattern)是常用的一种设计模式,多用于客户端交互和UI交互。观察者模式可定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。当一个对象的行为依赖于另一个对象的状态时,若不使用观察者模式则只能通过另一个线程不断的轮训监听。一个复杂的系统可能会开启很多线程来实现这一功能,而观察者模式的意义在于可以在单线程中在被观察者状态发生变化的时候通知到观察者,观察者再进行相应的操作,不会出现线程空转轮训。

善用优化组件和策略

缓冲 缓冲区是一块特定的内存区域。开辟缓冲区的目的是通过缓解应用程序上下层之间的性能差异,提高系统的性能。缓冲可以协调上层组件和下层组件的性能差。当上层组件性能优于下层组件时,可以有效减少上层组件对下层组件的等待时间。基于这样的结构,上层应用组件不需要等待下层组件真实地接受全部数据,即可返回操作,加快了上层组件的处理速度,从而提升系统整体性能。缓冲最常用的场景就是提高 I/O 速度,为此,JDK 中有不少 I/O 组件都提供了缓冲功能,例如:BufferedWriter、BufferedOutputStream。

缓存 缓存也是一块为了提升系统性能而开辟的内存空间。缓存的主要作用是暂存数据处理结果,以供下次访问使用。在很多场景中,数据的处理、获取可能会非常费时,当对这个数据的请求量很大时,频繁的 I/O 和数据处理会极大的降低性能,缓存的作用就是将来之不易的数据处理结果暂存起来,当有其它线程、客户端需要查询相同的数据资源时直接使用,这样就可以省略对这些数据的处理流程,而直接从缓存中获取处理结果。

对象池 对象池化,是目前常用的一种系统优化技术。它的核心思想是:缓存和共享,即对于那些被频繁使用的对象,在使用完后不立即将它们释放,而是将它们缓存起来,以供后续的应用程序重复使用,从而减少创建对象和释放对象的次数,进而改善应用程序的性能。缓存对象如同将对象放入一个池中,因此,这种策略被形象的称为“对象池化”。在实现细节上,对象池可能是一个数组,一个链表或者任何集合类。比较常用的比如线程池,数据库连接池。在程序中使用数据库连接池和线程池,可以有效地改善系统在高并发下的性能。这是两个非常重要的性能组件。任何对性能敏感的系统,都需要考虑合理配置这两个组件。此外,由于对象池技术将对象限制在一定的数量,也有效地减少了应用程序内存上的开销。

并行替代串行 随着多核时代的到来,CPU 的并行能力有了很大的提升。在这种背景下,传统的串行程序已经无法发挥 CPU 的最大潜能,造成系统资源的浪费。鉴于此,根据应用场景,应考虑并行替代串行的可行性,以便充分利用 CPU 资源。

负载均衡 对于一个应用,如果并发数非常多,单台服务器无法承受时,为保证应用程序的服务质量,就需要使用多台服务器协同工作,将系统负载尽可能均匀地分配到各个服务器节点上,使得各个服务器都能高效的工作,防止“空转”或“过载”。

时间换空间 时间换空间通常用于嵌入式设备或者内存、硬盘空间不足的场景。通过牺牲时间(CPU 资源或者网络资源等)的策略,实现原本需要更多内存或者硬盘空间才能完成的工作。例如,实现 a、b 两个变量的互换, a = a + b; b = a - b; a = a - b,以增加 CPU 运算的代价减少了内存空间的使用。

空间换时间 与时间换空间的方法相反,空间换时间则是尝试使用更多的内存或者磁盘空间换取时间(CPU 资源或者网络资源等),通过增加系统的内存消耗,来加快程序的运行速度。典型的应用实例就是缓存。

系统优化——编码优化

关于 Java 编码优化,涉及的点非常多,为指导编码,互联网公司通常都有各自的“编码规约”(如阿里的《Java 编码规约》),规约本质上就是 Java 编码优化点的集合。编码优化是一个相对庞大的体系,不便展开,本节仅介绍部分内容供读者参考。

合理指定类、方法的 final 修饰符

带有 final 修饰符的类是不可派生的。在 Java 核心 API 中,有许多应用 final 的例子,例如 java.lang.String,整个类都是 final 的。为类指定 final 修饰符可以让类不可以被继承,为方法指定 final 修饰符可以让方法不可以被重写。如果指定了一个类为 final,则该类所有的方法都是 final 的。 Java 编译器会寻找机会内联所有的 final 方法,内联对于提升 Java 运行效率作用重大。

及时关闭流

Java 编程过程中,进行数据库连接、I/O 流操作时务必小心,在使用完毕后,及时关闭以释放资源。因为对这些大对象的操作会造成系统大的开销,稍有不慎,将会导致严重的后果。

尽量减少对变量的重复计算

需要注意,对方法的调用,即使方法中只有一句语句,也是有消耗的,包括创建栈帧、调用方法时保护现场、调用方法完毕时恢复现场等。示例如下:

webp

修改后

webp

谨慎用异常

异常对性能不利。抛出异常首先要创建一个新的对象,Throwable 接口的构造函数调用名为 fillInStackTrace() 的本地同步方法,fillInStackTrace() 方法检查堆栈,收集调用跟踪信息。只要有异常被抛出,Java 虚拟机就必须调整调用堆栈,因为在处理过程中创建了一个新的对象。异常只能用于错误处理,不应该用来控制程序流程。

慎用锁

并发场景下,同步调用应该去考量锁的性能损耗:能用无锁数据结构,就不要用锁;能锁区块,就不要锁整个方法体;能用对象锁,就不要用类锁。

避免用 Apache Beanutils 进行属性的 copy

Apache BeanUtils 性能较差,可以使用其他方案,比如 Spring BeanUtils、 Cglib BeanCopier,注意均是浅拷贝。详情参考文章

核心数据结构

尽量使用 HashMap、ArrayList、StringBuilder,除非线程安全需要,否则不推荐使用 Hashtable、ConcurrentHashMap、Vector、StringBuffer,后三者由于使用同步机制而增加了性能开销。

List

理解 LinkedList 和 ArrayList 的区别: LinkedList:使用链表实现,随机插入删除数据相对高效,随机的定位查询相对低效,不会出现扩容问题; ArrayList:使用数组实现,随机的定位查询相对高效,随机插入删除数据行相对低效,会有扩容问题。

Map

理解 HasMap、LinkedHasMap、TreeMap 的区别: HasMap:分桶 hash,直接对 Key 做 hash 分桶存储,最好的情况下可以做到 O(1) 的访问,需要关注扩容以及冲突处理问题; LinkedHasMap:通过维护一个运行于所有条目的双向链表,保证了元素迭代的顺序,相较于 HasMap 性能降低,但是有序的; TreeMap:采用红黑树,对 Key 进行排序存储数据,由于红黑树是自平衡的,理论上可以做到 O(log(n)) 的访问。

Set

理解 HashSet、LinkedHashSet、TreeSet 的区别,都是对 Map 的封装,可以理解 Map 的 Key 就是 Set。

其它

循环内不要不断创建对象引用;

乘法和除法尽量使用移位操作;

程序运行过程中避免使用反射;

使用最有效率的方式去遍历 Map,推荐采用迭代器。

系统优化——并发设计优化

并发程序设计(concurrent programming)是指由若干个可同时执行的程序模块组成程序的程序设计方法。采用并发程序设计可以使外围设备和处理器并行工作,缩短程序执行时间,提高计算机系统效率。

并行设计模式

并发场景中,常用的 Java 多线程设计模式包括:Future 模式、Master-Worker 模式、Guarded Suspension 模式、不变模式和生产者-消费者模式等。

Future 模式

Future 模式的核心在于:去除了主函数的等待时间,并使得原本需要等待的时间段可以用于处理其它业务逻辑。鉴于 Future 模式在多线程中高频使用,JDK 中内置了 Future 模式的实现。这些类在 java.util.concurrent 包里面。其中最为重要的是 FutureTask 类,它实现了 Runnable 接口,作为单独的线程运行。在其 run() 方法中,通过 Sync 内部类调用 Callable 接口,并维护 Callable 接口的返回对象。当使用 FutureTask.get() 方法时,将返回 Callable 接口的返回对象。


webp

Master-Worker 模式

Master-Worker 模式是常用的并行模式之一,它的核心思想是:系统由两类进程协同工作,即 Master 进程和 Worker 进程,Master 负责接收和分配任务,Wroker 负责处理子任务。当各个 Worker 进程将子任务处理完成后,将结果返回给 Master 进程,由 Master 进程进行汇总,从而得到最终的结果,如下图所示:

webp

Master-Worker 模式的好处,它能够将一个大任务分解成若干个小任务并行执行,从而提高系统的吞吐量。而对于系统请求者 Client 来说,任务一旦提交,Master 进程会分配任务并立即返回,并不会等待系统全部处理完成后再返回,其处理过程是异步的。因此,Client 不会出现等待现象。

Guarded Suspension 模式

Guarded Suspension 意为保护暂停。其核心思想是仅当服务进程准备好时,才提供服务。设想一种场景,服务器可能会在很短时间内承受大量的客户端请求,客户端请求的数量可能超过服务器本身的即时处理能力,而服务端程序又不能丢弃任何一个客户请求。此时,最佳的处理方案莫过于让客户端要求进行排队,由服务端程序一个接一个处理。这样,既保证了所有的客户端请求均不丢失,同时也避免了服务器由于同时处理太多的请求而崩溃。

不变模式

一个对象的状态在对象被创建之后就不再变化,这就是所谓的不变模式。在并发设计中,为确保数据的一致性和正确性,有必要对对象进行同步,但是同步操作对系统性能有相当的损耗。因此可以使用一种不可改变的对象,依靠其不变性来确保并发操作在没有同步的情况下依旧保持一致性和正确性。不变模式的使用场景主要包括两个条件:

        ·     当对象创建后,其内部状态和数据不再发生任何改变;

        ·     对象需求被共享、被多线程频繁访问。

JDK 中不变模式的使用也非常广泛。其中最为典型的是 java.lang.String,此外还有元数据的包装类,如:java.lang.Double java.lang.Integer java.lang.Boolean 等等。

生产者消费者模式

生产者-消费者模式是一个经典的多线程设计模式,它为多线程的协作提供了良好的解决方案。在生产者-消费者模式中,通常有两类线程,即若干个生产者线程和若干个消费者线程。生产者线程负责提交用户请求,消费者线程负责处理用户请求。生产者和消费者之间通过共享内存缓冲区进行通信。

生产者-消费者模式中的内存缓冲区的主要功能是数据在多线程间的共享。此外,通过该缓冲区,可以缓解生产者和消费者之间的性能差。

多任务执行框架

Executors

为了更好的控制多线程,jdk 提供了一套线程框架 Executor,它在 Java.util.concurrent 包中,是 jdk 并发包的核心。Executors 扮演线程工厂的角色,其创建线程的方法如下:

newFixedThreadPool() 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO、 LIFO、优先级)执行。

newSingleThreadPool() 创建一个线程池,若线程空闲则立即执行,否则暂缓到队列中。

newCachedThreadPool() 返回一个可根据实际情况调整线程个数的线程池 ,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。

newScheduledThreadPool() 返回一个 ScheduledExecutorService 对象,支持定时及周期性任务执行。

若 Executors 工厂类无法满足我们的需求,可以自己去创建自定义的线程池。自定义线程池的构造方法如下:

webp

Condtion

在 Java 中,任意一个对象都拥有一组定义在 java.lang.Object 上监视器方法,包括 wait()、wait(long timeout)、notify()、notifyAll(),这些方法配合 synchronized 关键字一起使用可以实现等待/通知模式。同样,Condition 接口也提供了与 Object 类似的监视器方法,并通过与 Lock 配合来实现等待/通知模式。

webp

不难看出,Condition 的使用方式是比较简单的,需要注意的是使用 Condition 的等待/通知需要提前获取到与 Condition 对象关联的锁, Condition 对象由 Lock 对象创建。

Semaphore

Semaphore 中文含义是信号、信号系统。Semaphore 是一个线程同步的辅助类,可以维护当前访问自身的线程个数,并提供了同步机制。使用 Semaphore 可以控制同时访问资源的线程个数,例如,实现一个文件允许的并发访问数。

ThreadLocal

ThreadLocal 一般称为线程本地变量,它是一种特殊的线程绑定机制,将变量与线程绑定在一起,为每一个线程维护一个独立的变量副本。通过 ThreadLocal 可以将对象的可见范围限制在同一个线程内。

锁控制

并发问题产生的两个条件:

1.数据被多线程共享;

2.数据会改变。

只有这两个条件同时成立才可能出现多线程并发问题。上文中提到的“不变模式”就是通过破坏条件 2 来达到线程安全。而如果条件 2 无法实现,唯一的办法就是锁,锁可以实现共享资源的串行化从而保证多线程安全。

锁实现

Java 中实现锁的方式有很多,如:synchronized 关键字实现锁;ReentrantLock 可重入锁,相比 synchronized,可重入锁提供了更灵活的控制,需要自己手动释放锁,同时提供了可中断,可定时的能力;ReadWriteLock 读写锁,通过读写分离的机制,减少锁竞争提升系统并发能力。

通过锁可以实现共享可变数据的线程安全,但是在高并发的场景下可能因激烈的竞争导致并发能力下降。

锁的常用优化策略

减少锁的持有时间: 在锁竞争的过程中,单个线程对锁的持有时间和性能有着直接关系。如果单线程持有锁的时间过长,那么锁的竞争程度也就越激烈。因此,在系统开发过程中尽量减少锁的持有时间。

减少锁的粒度: 减少锁粒度也是降低多线程锁竞争的一种有效手段,这种技术典型的应用场景就是 ConcurrentHashMap 对 Collections.synchronizedMap() 的优化。LinkedBlockingQueue 中将 put 和 take 分别有自己的锁,实现分离。

volatile: 关键字实现无锁安全的线程共享;

AtomicXX: 原子类变量使用;

CAS 思想: 实现无锁的线程安全访问;

Amino 框架: 提供了很多无锁线程安全的数据结构;

系统优化—— JVM 调优

关于 Java 应用的系统优化,JVM 调优是其核心点之一,而JVM调优最重要的工作就是 Full GC 的优化。本节将从 Java 虚拟机切入,循序渐进,介绍 JVM 调优的原理和常用的调优策略。

虚拟机介绍

虚拟机内存模型

webp

方法区(Method Area):

对于我们使用 HotSpot 虚拟机的程序员来说,方法区即平时我们所说的永久代(Perm Gen),它用于存储已被虚拟机加载的类信息、常量、以及静态变量等数据。虽然 Java 虚拟机规范将方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做非堆(No-Heap),目的是为了与堆进行区分,HotSpot 虚拟机设计团队只是为了让方法区与 Java 堆统一使用分代 GC 机制,才将它命名为永久代,与新生代(New Gen)与老年代(Tenured Gen)使用同一套内存管理代码。运行时常量池(Runtime Constant Pool)属于方法区的一部分。

虚拟机栈(VM Stack): 虚拟机栈描述的是 Java 方法执行的内存模型,每个方法在执行同时会创建一个栈帧(Stack Frame),用于存储局部变量表(存放编译可知的基本数据类型,对象引用和 returnAddress),操作数栈,动态链接,方法出口等信息。每个方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中的入栈与出栈的过程。该部分是线程隔离的,随着线程的创建而创建,销毁而销毁。

本地方法栈(Native Method Stack): 与虚拟机栈类似,区别就是虚拟机栈执行的是 Java 方法(字节码),而本地方法栈执行的是 Native 方法。

堆(Heap): 堆是虚拟机所管理的内存最大的一块,这块内存唯一的作用就是存放对象实例。几乎所有的对象实例以及数组都要在堆上分配内存。同时,我们平时所说的垃圾回收也大部分(堆外还有方法区与直接内存)集中在堆上,这是垃圾收集器管理的最主要的区域,后面会对堆这块进行详细介绍。该区域是线程共享的,故需要对其进行回收。

程序计数器(Program Counter Register): 可以当作当前线程的执行的字节码的行号指示器,分支、循环、跳转、异常处理等基础功能都需要依赖程序计数器来指定到下一条要执行的字节码指令。该部分是线程隔离的,随着线程的创建而创建,销毁而销毁。

直接内存(Direct Memory): 也就是狭义上的堆外内存,jdk1.4 后引入了 NIO 包,为了提高 I/O 性能,该包下面提供了一个使用 Native 方法直接分配堆外内存的类-DirectByteBuffer,数据存储在直接内存,堆中的 DirectByteBuffer 对象作为该块内存的引用。该部分内存的分配不受 Java 堆大小的限制,只会受本机总内存以及处理器寻址空间的限制。JDK5.0 之后,代码中能直接操作本地内存的方式有 2 种:使用未公开的 Unsafe 和 NIO 包下 ByteBuffer。

堆内存的分配与管理


webp

从内存回收的角度看,由于现代虚拟机基本上都使用的分代收集算法,因此将堆内存分为新生代(Young Generation)、老年代(Old Generation)与永久代(Permanent),永久代前面介绍过,是一种比较特殊的堆内存,JDK1.8 开始废弃了永久代。由于新生代对象朝生夕亡(仅大约 2% 存活)的特性,因此新生代 GC 时采用的基本上都是复制算法,将新生代内存分成三块:Eden 区,From Survivor Space,To Survivor Space,默认三块的比例为 8:1:1,对象创建时在 Eden 区分配内存,GC 时将 Eden 区存活的对象复制到 Survivor 区,然后清理 Eden 区内存。

对象优先在 Eden 分配: 大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够的空间进行分配时,虚拟机将会进行一次 Minor GC。

大对象直接进入老年代: 这样做的目的是为了防止在 Eden 区以及两个 survivor 区发生大量的内存的复制。虚拟机提供了一个参数 -XX:PretenureSizeThreshold 参数用来设置这个大对象的值。

长期存活的对象将进入老年代: 虚拟机给每个对象分配了一个对象年龄(Age)计数器,如果对象在 Eden 区出生并且经过第一次 Minor GC 后仍然存活,并且能够被 Survivor 区容纳的话,将会被移到 Survivor 空间中,并且年龄设为 1。对象在 Survivor 空间中每“熬过”一次 Minor GC,年龄就增加 1 岁。当他年龄增加到一定程度(默认 15)时,就会晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过 -XX:MaxTenuringThreshold 设置。

空间比重使对象将进入老年代: Survivor 空间中相同年龄的所有对象大小的总和大于 Survivor 空间的一半时,年龄等于或者大于该年龄的对象就可以直接进入老年代中,而无需等到 MaxTenuringThreshold 这个阈值。

Full GC 的发生条件: 在发生 Minor GC 之前,虚拟机会去检查老年代中最大可用连续内存空间是否大于新生代所有对象总空间,如果这个条件成立,那么可以确保这次 minor GC 是安全的,如果不成立,则虚拟机回去查看 HandlePromotionFailure 设置的值是否允许担保失败。如果允许,则虚拟机会继续检查老年代中最大可用连续内存空间大小是否大于历次晋升到老年代对象的平均大小,如果大于,则会尝试进行一次 Minor GC,即便这次 Minor GC 是有风险的。如果小于,或者 HandlePromotionFailure 设置不允许冒险,那这时要改为进行一次 Full GC。

特别说明: 在 JDK 6 update 24 后,HandlePromotionFailure 不再会影响虚拟机分配担保策略, 规则变为只要老年代连续可用内存大小大于新生代所有对象总大小或者历次晋升老年代对象的平均大小就进行 Minor GC,否则进行 Full GC。

垃圾收集基础

判断哪些对象被回收

在进行垃圾收集之前,需要做的一件事情就是判断对象对象是否存活。如何判断对象存活,JVM 采用的是可达性分析算法:

目前商业虚拟机的主流实现中,都通过该种方式判断对象是否可回收。主要思路是定义一系列叫做“GC Root”的根节点,从这些根节点往下搜索,走过的路径称为引用链,当一个对象到“GC Root”没有任何引用链相连的时候,则证明该对象是不可用的。如下图所示,object5、object6 和 object7 虽然互相有关联,但是它们到 GC Roots 是不可达的,所以它们将会被判定为是可回收的对象。

webp

如何回收对象

复制算法:

现代商业虚拟机都使用这种方法来回收新生代。该算法的主要思路是:将内存分为两块,对象创建都在某一块上分配内存,当该块内存快满时触发垃圾回收,回收时将该块内存中存活的对象全部复制到另外一块内存中,然后将这块内存全部回收掉。这种方法的好处是由于是整块内存的回收,因此不会产生内存碎片。现代商业虚拟机都是将内存分为三块,就是我们平时所说的 1 个 Eden 区以及 2 个 Survivor 区,由于新生代对象一般都是朝生夕亡(98% 亡),所以一般默认比例 8:1:1。垃圾回收时,将 Eden 区以及使用的 1 个 Survivor 区上存活的对象复制到另一个 Survivor 区上,然后清空Eden与之前的那个 Survivor 区。

标记清除算法: 该算法的主要思路是:先标记出所有需要被回收的对象,在标记完成后统一回收被标记的对象,标记过程就是前面介绍的判断对象不存活了就标记。该方法缺点也很明显,就是会产生大量的内存碎片,当有大对象进来时,可能总体空间是足够的,但是确找不到这样一块连续的内存空间而出现 OOM 异常。

标记整理/标记压缩算法: 该算法的主要思路是:先标记出所有需要被回收的对象,标记过程与上面一致,但是不是标记完就回收,而是让没有被标记的对象(存活的对象)全部向一端移动,最后清理掉边界以外的内存。该算法常被用来进行老年代的垃圾回收。

垃圾收集器介绍

新生代收集器

Serial 收集器: 最基本的,历史最悠久的收集器。单线程收集器,在进行垃圾回收时必须暂停掉所有的用户线程,即“Stop The World”。但是它也有一个优点就是简单高效。采用的是复制算法,通过 -XX:+UseSerialGC 配置。

ParNew 收集器: 其实就是 Serial 收集器的多线程版本,在单 CPU 的情况下效果不一定会比 Serial 好。但是它的优势是可以配合 CMS 收集器进行工作,采用的是复制算法。通过 -XX:+UseParNewGc 配置。

Parallel Scavenge 收集器: Parallel Scavenge 也是一款多线程收集器,与 ParNew 的不同之处在于关注点不一样,其它收集器主要关注尽量降低 STW 的时间,而它主要关注在吞吐量,采用的是复制算法。通过 -XX:+UseParallelGC 配置。

老年代收集器

Serial Old 收集器:

是 Serial 收集器的老年代版本,采用的是“标记整理/标记压缩算法”,通过 -XX:+UseSerialOldGC 配置。

Parallel Old 收集器: 是 Parallel Scavenge 收集器的老年代版本,采用多线程以及“标记整理/标记压缩算法”。通过 -XX:+UseParallelOldGC 配置。

CMS 收集器: 这是一款以获取最短回收停顿为目标的收集器,CMS(Concurrent Mark Sweep),从名字可以看到,这是一款使用“标记-清理”算法的并发收集器。主要分为:初始标记(CMS initial mark),并发标记(CMS concurrent mark),重新标记(CMS remark),并发清除(CMS concurrent sweep)4 步,其中初始标记与重新标记两步仍然会导致“Stop The World”,但是时间会比之前的收集器短许多。通过 -XX:+UseConcMarkSweepGC 配置。

G1(GarbageFirst)收集器: 当前收集器发展的最前沿的成果之一,能充分利用多 CPU 的硬件优势,来缩短 STW 的时间,可以不需要其它收集器的配合就可以管理整个堆内存,它最大的一个优势就是可预测停顿。

JVM 配置常用参数

堆参数

webp

回收器参数

webp

如上表所示,目前主要有串行、并行和并发三种,对于大内存的应用而言,串行的性能太低,因此使用到的主要是并行和并发两种。并行和并发 GC 的策略通过 UseParallelGC 和 UseConcMarkSweepGC 来指定,还有一些细节的配置参数用来配置策略的执行方式。例如:XX:ParallelGCThreads, XX:CMSInitiatingOccupancyFraction 等。 通常:Young 区对象回收只可选择并行(耗时间),Old 区选择并发(耗 CPU)。

项目中常用配置

webp

常用组合

webp

常用 GC 调优策略

GC 调优原则

在调优之前,我们需要记住下面的原则:


webp

GC 调优目的

webp

GC 调优策略

策略 1 将新对象预留在新生代,由于 Full GC 的成本远高于 Minor GC,因此尽可能将对象分配在新生代是明智的做法,实际项目中根据 GC 日志分析新生代空间大小分配是否合理,适当通过“-Xmn”命令调节新生代大小,最大限度降低新对象直接进入老年代的情况。

策略 2

大对象进入老年代,虽然大部分情况下,将对象分配在新生代是合理的。但是对于大对象这种做法却值得商榷,大对象如果首次在新生代分配可能会出现空间不足导致很多年龄不够的小对象被分配的老年代,破坏新生代的对象结构,可能会出现频繁的 full gc。因此,对于大对象,可以设置直接进入老年代(当然短命的大对象对于垃圾回收老说简直就是噩梦)。-XX:PretenureSizeThreshold 可以设置直接进入老年代的对象大小。

策略 3 合理设置进入老年代对象的年龄,-XX:MaxTenuringThreshold 设置对象进入老年代的年龄大小,减少老年代的内存占用,降低 full gc 发生的频率。

策略 4 设置稳定的堆大小,堆大小设置有两个参数:-Xms 初始化堆大小,-Xmx 最大堆大小。

策略5 注意: 如果满足下面的指标,则一般不需要进行 GC 优化:

webp

系统优化——缓存设计

常用缓存设计

分布式缓存(如 Redis)

采用分布式缓存很好的解决了数据一致性问题,所有业务系统共享分布式缓存系统数据。但稳定性和效率相对于本地缓存来说会低一些。

本地缓存 (如 Guva Cache、 map )

本地缓存虽然效率较高,但是各服务器拥有自己的缓存数据,不是共享的,数据一致性的问题很难解决。大都通过定时任务定时刷新来保证数据准确性,同时,本地缓存还有内存限制,不易存储大量数据,适用于存储读多写少的公共数据。

通常,缓存最重要的问题是如何保证缓存数据与 DB 数据的一致性。缓存数据一致性解决方案:

webp

缓存对象设计

缓存对象粒度

对于本地磁盘或分布是缓存系统来说,其缓存的数据一般都不是结构化的,而是半结构化或序列化的。这就导致了我们读取缓存时,很难直接拿到程序最终想要的结果。那么,缓存对象中到底如何存放数据呢:一种数据一个对象,简单,读取写入都快,但是种类一多,缓存的管理成本就会很高;多种数据放在一个对象里,方便,一块全出来了,想用哪个都可以,但如果只需要其中一种数据,其它的就浪费了,网络带宽和传输延迟的消耗也很可观。

综上分析,不同的业务场景应使用不同的缓存粒度,折衷权衡。缓存一般都是访问频率非常高的数据,各个点的累积效应可能是非常巨大的!当然,有些缓存系统的设计也要求我们必须考虑缓存对象的粒度问题,比如说 Memcached,其 chunk 设计要求业务要能很好的控制其缓存对象的大小,淘宝的 Tair 也是,对于尺寸超过1M的对象,处理效率将大为降低。

像 Redis 这种提供同时提供了 Map、List 结构支持的系统来说,虽然增加了缓存结构的灵活性,但最多也只能算是半结构化缓存,还无法做到像本地内存那样的灵活性。

粒度设计的过粗还会遇到并发问题。一个大对象里包含的多种数据,很多地方多要用,这时如果使用的是缓存修改模式而不是过期模式,那么很可能会因为并发更新而导致数据被覆盖,版本控制是一种解决方法,但是这样会使缓存更新失败的概率大大增加,而且有些缓存系统也不提供版本支持(比如说用的很广泛的Memcached)。

缓存对象结构

对于一个缓存对象来说,并不是其粒度越小,体积就越小。如果你的一个字符串就有 1M 大小,那也是很恐怖的。数据的结构决定着你读取的方式,举个很简单的例子,集合对象中,List 和 Map 两种数据结构,由于其底层存储方式不同,所以使用的场景也不一样:前者更适合有序遍历,而后者适合随机存取。回想一下,你是不是曾经在程序中遇到过为了合并两个 list 中的数据,而不得不循环嵌套?所以,根据具体应用场景去为缓存对象设计一个更合适的存储结构,也是一个很值得注意的点。

缓存更新策略

缓存的更新策略主要有两种:被动失效和主动更新,下面分别进行介绍;

被动失效

一般来说,缓存数据主要是服务读请求的,并设置一个过期时间;或者当数据库状态改变时,通过一个简单的 delete 操作,使数据失效掉;当下次再去读取时,如果发现数据过期了或者不存在了,那么就重新去持久层读取,然后更新到缓存中,这即是所谓的被动失效策略。

但是在被动失效策略中存在一个问题,就是从缓存失效或者丢失开始直到新的数据再次被更新到缓存中的这段时间,所有的读请求都将会直接落到数据库上,而对于一个高访问量的系统来说,这有可能会带来风险。所以我们换一种策略就是,当数据库更新时,主动去同步更新缓存,这样在缓存数据的整个生命期内,就不会有空窗期,前端请求也就没有机会去亲密接触数据库。

主动更新

前面我们提到主动更新主要是为了解决空窗期的问题,但是这同样会带来另一个问题,就是并发更新的情况:在集群环境下,多台应用服务器同时访问一份数据是很正常的,这样就会存在一台服务器读取并修改了缓存数据,但是还没来得及写入的情况下,另一台服务器也读取并修改旧的数据,这时候,后写入的将会覆盖前面的,从而导致数据丢失。这也是分布式系统开发中,必然会遇到的一个问题。解决的方式主要有两种:

锁控制: 这种方式一般在客户端实现(在服务端加锁是另外一种情况),其基本原理就是使用读写锁,即任何进程要调用写方法时,先要获取一个排它锁,阻塞住所有的其它访问,等自己完全修改完后才能释放。如果遇到其它进程也正在修改或读取数据,那么则需要等待。

锁控制虽然是一种方案,但是很少有真的这样去做的,其缺点显而易见,其并发性只存在于读操作之间,只要有写操作存在,就只能串行。

版本控制: 这种方式有两种实现,一种是单版本机制,即为每份数据保存一个版本号,当缓存数据写入时,需要传入这个版本号,然后服务端将传入的版本号和数据当前的版本号进行比对,如果大于当前版本,则成功写入,否则返回失败。这样解决方式比较简单,但是增加了高并发下客户端的写失败概率。

还有一种方式就是多版本机制,即存储系统为每个数据保存多份,每份都有自己的版本号,互不冲突,然后通过一定的策略来定期合并,再或者就是交由客户端自己去选择读取哪个版本的数据。很多分布式缓存一般会使用单版本机制,而很多 NoSQL 则使用后者。

数据对象序列化

由于独立于应用系统,分布式缓存的本质就是将所有的业务数据对象序列化为字节数组,然后保存到自己的内存中。所使用的序列化方案也自然会成为影响系统性能的关键点之一。一般来说,我们对一个序列化框架的关注主要有以下几点:

        1.序列化速度,即对一个普通对象,将其从内存对象转换为字节数组需要多长时间,这个当然是越快越好;

        2.对象压缩比,即序列化后生成对象的与原内存对象的体积比;

        3.支持的数据类型范围,序列化框架都支持什么样的数据结构,对于大部分的序列化框架来说,都会支持普通的对象类型,但是对于复杂对象(比如说多继承关系、交叉引用、集合类等)可能不支持或支持得不够好;

        4.易用性,一个好的序列化框架必须也是使用方便的,不需要用户做太多的依赖或者额外配置。

序列化可选框架:Java 源生序列化、Google Protobuf、Hessian、Kryo。

欢迎工作一到五年的Java工程师朋友们加入我的个人粉丝群Java填坑之路:789337293

群内提供免费的Java架构学习资料(里面有高可用、高并发、高性能及分布式、Jvm性能调优、Spring源码,MyBatis,Netty,Redis,Kafka,Mysql,Zookeeper,Tomcat,Docker,Dubbo,Nginx等多个知识点的架构资料)合理利用自己每一分每一秒的时间来学习提升自己,不要再用"没有时间“来掩饰自己思想上的懒惰!趁年轻,使劲拼,给未来的自己一个交代!


猜你喜欢

转载自blog.51cto.com/13399166/2387601