- 一、Java性能调优概述
- 1.1 性能调优标准
- 1.2 制定性能调优策略
- 二、Java编程性能调优
- 2.1 字符串
- 2.2 正则表达式
- 2.3 ArrayList和linkedList的选择
- 2.4 使用Stream提高遍历集合效率
- 2.5 HashMap优化
- 2.6 高并发下I/O优化
- 2.7 避免使用Java序列化
- 2.8 优化RPC网络通信
- 2.9 NIO优化
- 三、多线程性能优化
- 3.1 Synchronized优化
- 3.2 Lock优化
- 3.3 乐观锁优化
- 3.4 上下文切换优化
- 3.5 并发容器的使用
- 3.6 线程池大小的设置
- 3.7 用协程来优化多线程业务
在项目开发的初期,有必要过于在意性能优化,这样反而会疲于性能优化,不仅不会给系统性能带来提升,还会影响到开发进度,甚至获得相反的效果,给系统带来新的问题。
只需要在代码层面保证有效的编码,比如,减少磁盘 I/O *** 作、降低竞争锁的使用以及使用高效的算法等等。遇到比较复杂的业务,我们可以充分利用设计模式来优化业务代码。
在系统编码完成之后,我们就可以对系统进行性能测试了。这时候,产品经理一般会提供线上预期数据,我们在提供的参考平台上进行压测,通过性能分析、统计工具来统计各项性能指标,看是否在预期范围之内。
在项目成功上线后,我们还需要根据线上的实际情况,依照日志监控以及性能统计日志,来观测系统性能问题,一旦发现问题,就要对日志进行分析并及时修复问题。
可能成为系统的性能瓶颈的计算机资源:
- 1、CPU
有的应用需要大量计算,他们会长时间、不间断地占用 CPU 资源,导致其他资源无法争夺到 CPU 而响应缓慢,从而带来系统性能问题。例如,代码递归导致的无限循环,正则表达式引起的回溯,JVM 频繁的 FULL GC,以及多线程编程造成的大量上下文切换等,这些都有可能导致 CPU 资源繁忙。 - 2、内存
Java 程序一般通过 JVM 对内存进行分配管理,主要是用 JVM 中的堆内存来存储Java 创建的对象。系统堆内存的读写速度非常快,所以基本不存在读写性能瓶颈。但是由于内存成本要比磁盘高,相比磁盘,内存的存储空间又非常有限。所以当内存空间被占满,对象无法回收时,就会导致内存溢出、内存泄露等问题。 - 3、磁盘 I/O
磁盘 I/O 读写的速度要比内存慢。 - 4、网络
网络对于系统性能来说,也起着至关重要的作用。带宽过低的话,对于传输数据比较大,或者是并发量比较大的系统,网络就很容易成为性能瓶颈。 - 5、异常
如果在高并发的情况下引发异常,持续地进行异常处理,那么系统的性能就会明显地受到影响。 - 6、数据库
大部分系统都会用到数据库,而数据库的 *** 作往往是涉及到磁盘 I/O 的读写。大量的数据库读写 *** 作,会导致磁盘 I/O 性能瓶颈,进而导致数据库 *** 作的延迟性。对于有大量数据库读写 *** 作的系统来说,数据库的性能优化是整个系统的核心。 - 7、锁竞争
锁的使用可能会带来上下文切换,从而给系统带来性能开销。如何合理地使用锁资源,优化锁资源,就需要你了解更多的 *** 作系统知识、Java 多线程编程基础,积累项目经验,并结合实际场景去处理相关问题。
衡量一般系统的性能的指标:
- 1、响应时间
响应时间是衡量系统性能的重要指标之一,响应时间越短,性能越好,一般一个接口的响应时间是在毫秒级。可以把响应时间自下而上细分为以下几种:
数据库响应时间:数据库 *** 作所消耗的时间,往往是整个请求链中最耗时的;
服务端响应时间:服务端包括 Nginx 分发的请求所消耗的时间以及服务端程序执行所消耗的时间;
网络响应时间:这是网络传输时,网络硬件需要对传输的请求进行解析等 *** 作所消耗的时间;
客户端响应时间:对于普通的 Web、App 客户端来说,消耗时间是可以忽略不计的,但如果你的客户端嵌入了大量的逻辑处理,消耗的时间就有可能变长,从而成为系统的瓶颈。 - 2、吞吐量
我们往往会比较注重系统接口的 TPS(每秒事务处理量),因为 TPS 体现了接口的性能,TPS 越大,性能越好。在系统中,我们也可以把吞吐量自下而上地分为两种:磁盘吞吐量和网络吞吐量。
磁盘性能有两个关键衡量指标:
- IOPS(Input/Output Per Second)
每秒的输入输出量(或读写次数),这种是指单位时间内系统能处理的 I/O 请求数量,I/O 请求通常为读或写数据 *** 作请求,关注的是随机读写性能。适应于随机读写频繁的应用,如小文件存储(图片)、OLTP 数据库、邮件服务器。 - 数据吞吐量
指单位时间内可以成功传输的数据量。对于大量顺序读写频繁的应用,传输大量连续数据,例如,电视台的视频编辑、视频点播 VOD(Video On Demand),数据吞吐量则是关键衡量指标。
网络吞吐量:指网络传输时没有帧丢失的情况下,设备能够接受的最大数据
速率。网络吞吐量不仅仅跟带宽有关系,还跟 CPU 的处理能力、网卡、防火墙、外部接口以及 I/O 等紧密关联。而吞吐量的大小主要由网卡的处理能力、内部程序算法以及带宽大小决定。
- 3、计算机资源分配使用率
通常由 CPU 占用率、内存使用率、磁盘 I/O、网络 I/O 来表示资源使用率。 - 4、负载承受能力
当系统压力上升时,你可以观察,系统响应时间的上升曲线是否平缓。这项指标能直观地反馈给你,系统所能承受的负载压力极限。
面对日渐复杂的系统,制定合理的性能测试,可以提前发现性能瓶颈,然后有针对性地制定调优策略。总结一下就是“测试 - 分析 - 调优”三步走。
性能测试是提前发现性能瓶颈,保障系统性能稳定的必要措施。
两种常用的测试方法:微基准性能测试和宏基准性能测试。
- 1、微基准性能测试
微基准性能测试可以精准定位到某个模块或者某个方法的性能问题,特别适合做一个功能模块或者一个方法在不同实现方式下的性能对比。例如,对比一个方法使用同步实现和非同步实现的性能。 - 2、宏基准性能测试
宏基准性能测试是一个综合测试,需要考虑到测试环境、测试场景和测试目标。
首先看测试环境,我们需要模拟线上的真实环境。然后看测试场景。我们需要确定在测试某个接口时,是否有其他业务接口同时也在平行运行,造成干扰。如果有,请重视,因为你一旦忽视了这种干扰,测试结果就会出现偏差。
最后看测试目标。我们的性能测试是要有目标的,这里可以通过吞吐量以及响应时间来衡量系统是否达标。不达标,就进行优化;达标,就继续加大测试的并发数,探底接口的TPS(最大每秒事务处理量),这样做,可以深入了解到接口的性能。除了测试接口的吞吐量和响应时间以外,我们还需要循环测试可能导致性能问题的接口,观察各个服务器的CPU、内存以及 I/O 使用率的变化。
在做性能测试时,还要注意的一些问题:
- 1、热身问题
在 Java 编程语言和环境中,.java 文件编译成为 .class 文件后,机器还是无法直接运行.class 文件中的字节码,需要通过解释器将字节码转换成本地机器码才能运行。为了节约内存和执行效率,代码最初被执行时,解释器会率先解释执行这段代码。
随着代码被执行的次数增多,当虚拟机发现某个方法或代码块运行得特别频繁时,就会把这些代码认定为热点代码(Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机将会通过即时编译器(JIT compiler,just-in-time compiler)把这些代码编译成与本地平台相关的机器码,并进行各层次的优化,然后存储在内存中,之后每次运行代码时,直接从内存中获取即可。
所以在刚开始运行的阶段,虚拟机会花费很长的时间来全面优化代码,后面就能以最高性能执行了。 - 2、性能测试结果不稳定
可以通过多次测试,将测试结果求平均,或者统计一个曲线图,只要保证我们的平均值是在合理范围之内,而且波动不是很大,这种情况下,性能测试就是通过的。 - 3、 多JVM情况下的影响
应该尽量避免线上环境中一台机器部署多个JVM的情况。
从应用层到 *** 作系统层的几种调优策略:
- 1、优化代码
应用层的问题代码往往会因为耗尽系统资源而暴露出来。
还有一些是非问题代码导致的性能问题,这种往往是比较难发现的。例如,linkedList 集合,如果使用 for 循环遍历该容器,将大大降低读的效率,这种效率的降低很难导致系统性能参数异常。此时可以改用 Iterator (迭代器)迭代循环该集合,这是因为 linkedList是链表实现的,如果使用 for 循环获取元素,在每次循环获取元素时,都会去遍历一次List,这样会降低读的效率。 - 2、优化设计
面向对象有很多设计模式,可以帮助我们优化业务层以及中间件层的代码设计。优化后,不仅可以精简代码,还能提高整体性能。 - 3、优化算法
- 4、时间换空间
有时候系统对查询时的速度并没有很高的要求,反而对存储空间要求苛刻,这个时候可以考虑用时间来换取空间。 - 5、空间换时间
现在很多系统都是使用的 MySQL 数据库,较为常见的分表分库是典型的使用空间换时间的案例。
因为 MySQL 单表在存储千万数据以上时,读写性能会明显下降,这个时候我们需要将表数据通过某个字段 Hash 值或者其他方式分拆,系统查询数据时,会根据条件的 Hash 值判断找到对应的表,因为表数据量减小了,查询性能也就提升了。 - 6、参数调优
以上都是业务层代码的优化,除此之外,JVM、Web 容器以及 *** 作系统的优化也是非常关键的。
为了保证系统的稳定性,我们还需要采用一些兜底策略。示例:
- 1、限流
对系统的入口设置最大访问限制。同时采取熔断措施,友好地返回没有成功的请求。 - 2、实现智能化横向扩容
智能化横向扩容可以保证当访问量超过某一个阈值时,系统可以根据需求自动横向新增服务。 - 3、提前扩容
这种方法通常应用于高并发系统。
目前很多公司使用 Docker 容器来部署应用服务。这是因为 Docker 容器是使用Kubernetes 作为容器管理系统,而 Kubernetes 可以实现智能化横向扩容和提前扩容Docker 服务。
调优策略简单总结:
String对象优化过程:
在Java6以及之前的版本中,String 对象是对 char 数组进行了封装实现的对象,主要有四个成员变量:char 数组、偏移量 offset、字符数量 count、哈希值 hash。String 对象是通过 offset 和 count 两个属性来定位 char[] 数组,获取字符串。这么做可以高效、快速地共享数组对象,同时节省内存空间,但这种方式很有可能会导致内存泄漏。
从Java7开始到 Java8版本,Java 对 String 类做了一些改变。String 类中不再有offset 和 count 两个变量了。这样的好处是 String 对象占用的内存稍微少了些,同时,String.substring 方法也不再共享 char[],从而解决了使用该方法可能导致的内存泄漏问题。
从Java9版本开始,工程师将 char[] 字段改为了 byte[] 字段,又维护了一个新的属性coder,它是一个编码格式的标识。
一个 char 字符占 16 位,2 个字节。这个情况下,存储单字节编码内的字符(占一个字节的字符)就显得非常浪费。JDK1.9 的 String 类为了节约内存空间,于是使用了占8 位,1 个字节的 byte 数组来存放字符串。
String 类被 final 关键字修饰,char[] 被 final+private 修饰,代表了String 对象不可被更改。这样做的好处:
- 1、保证 String 对象的安全性
假设 String 对象是可变的,那么 String 对象将可能被恶意修改。 - 2、保证 String 对象的安全性
保证 hash 属性值不会频繁变更,确保了唯一性,使得类似 HashMap 容器才能实现相应的 key-value 缓存功能。 - 3、可以实现字符串常量池
String 对象的优化方式:
- 1、字符串拼接
做字符串拼接的时候,建议显式地使用 StringBuilder 来提升系统性能。因为String对象直接相加时,底层还是用StringBuilder来实现的。 - 2、合理使用String.intern
在每次赋值的时候使用 String 的 intern 方法,如果常量池中有相同值,就会重复使用该对象,返回对象引用,这样一开始的对象就可以被回收掉。
示例:
String a = new String("abc").intern(); String b = new String("abc").intern(); if(a == b) //a==b System.out.println("a==b");
在字符串常量中,默认会将对象放入常量池;在字符串变量中,对象是会创建在堆内存中,同时也会在常量池中创建一个字符串对象,复制到堆内存对象中,并返回堆内存对象引用。
如果调用 intern 方法,会去查看字符串常量池中是否有等于该对象的字符串,如果没有,就在常量池中新增该对象,并返回该对象引用;如果有,就返回常量池中的字符串引用。堆内存中原有的对象由于没有引用指向它,将会通过垃圾回收器回收。
使用 intern 方法需要注意的一点是,一定要结合实际场景。因为常量池的实现是类似于一个 HashTable 的实现方式,HashTable 存储的数据越大,遍历的时间复杂度就会增加。如果数据过大,会增加整个字符串常量池的负担。
- 3、合理使用字符串的分割方法
应该慎重使用 Split() 方法,可以用 String.indexOf() 方法代替 Split() 方法完成字符串的分割。
构造正则表达式语法的元字符,由普通字符、标准字符、限定字符(量词)、定位字符(边界字符)组成:
正则表达式的优化方法:
- 1、 少用贪婪模式,多用独占模式
简单来说,贪婪模式:在数量匹配中,如果单独使用 +、 ? 、* 或{min,max} 等量词,正则表达式会匹配尽可能多的内容。贪婪模式示例:
regex = "ab{1,3}c"
懒惰模式:正则表达式会尽可能少地重复匹配字符。如果匹配成功,它会继续匹配剩余的字符串。例如,在上面例子的字符后面加一个“?”,就可以开启懒惰模式。示例:
regex = "ab{1,3}?c"
独占模式:一样会最大限度地匹配更多内容;不同的是,在独占模式下,匹配失败就会结束匹配,不会发生回溯问题。还是上边的例子,在字符后面加一个“+”,就可以开启独占模式。示例:
regex = "ab{1,3}+bc"
- 2、 减少分支选择
分支选择类型“(X|Y|Z)”的正则表达式会降低性能,要尽量减少使用。如果一定要用,可以通过以下几种方式来优化:
- 需要考虑选择的顺序,将比较常用的选择项放在前面,使它们可以较快地被匹配;
- 可以尝试提取共用模式,例如,将“(abcd|abef)”替换为“ab(cd|ef)”,后者匹配速度较快,因为 NFA 自动机会尝试匹配 ab,如果没有找到,就不会再尝试任何选项;
- 如果是简单的分支选择类型,我们可以用三次 index 代替“(X|Y|Z)”,如果测试的话,你就会发现三次 index 的效率要比“(X|Y|Z)”高出一些。
以往的经验来看,如果使用正则表达式能使代码简洁方便,那么在做好性能排查的前提下,可以去使用;如果不能,那么正则表达式能不用就不用,以此避免造成更多的性能问题。
2.3 ArrayList和linkedList的选择 ArrayList 实现了 List 接口,继承了 AbstractList 抽象类,底层是数组实现的,并且实现了自增扩容数组大小。ArrayList 还实现了 Cloneable 接口和 Serializable 接口,所以他可以实现克隆和序列化。
ArrayList 还实现了 RandomAccess 接口。RandomAccess 接口是一个标志接口,他标志着“只要实现该接口的 List 类,都能实现快速随机访问”。
ArrayList 属性主要由数组长度 size、对象数组 elementData、初始化容量default_capacity 等组成, 其中初始化容量默认大小为 10。elementData 被关键字transient 修饰。
如果采用外部序列化法实现数组的序列化,会序列化整个数组。ArrayList 为了避免这些没有存储数据的内存空间被序列化,内部提供了两个私有方法 writeObject 以及 readObject来自我完成序列化与反序列化,从而在序列化与反序列化数组时节省了空间和时间。因此使用 transient 修饰数组,是防止对象数组被其他外部方法序列化。
linkedList 是基于双向链表数据结构实现的,linkedList 定义了一个 Node 结构,Node结构中包含了 3 个部分:元素内容 item、前指针 prev 以及后指针 next。
linkedList 就是由 Node 结构对象连接而成的一个双向链表。在 JDK1.7 之前,linkedList 中只包含了一个 Entry 结构的 header 属性,并在初始化的时候默认创建一个空的 Entry,用来做 header,前后指针指向自己,形成一个循环双向链表。
在 JDK1.7 之后,linkedList 做了很大的改动,对链表进行了优化。链表的 Entry 结构换成了 Node,内部组成基本没有改变,但 linkedList 里面的 header 属性去掉了,新增了一个 Node 结构的 first 属性和一个 Node 结构的 last 属性。这样做有以下几点好处:
- first/last 属性能更清晰地表达链表的链头和链尾概念;
- first/last 方式可以在初始化 linkedList 的时候节省 new 一个 Entry;
- first/last 方式最重要的性能优化是链头和链尾的插入删除 *** 作更加快捷了。
在 linkedList 删除元素的 *** 作中,我们首先要通过循环找到要删除的元素,如果要删除的位置处于 List 的前半段,就从前往后找;若其位置处于后半段,就从后往前找。因此,无论要删除较为靠前或较为靠后的元素都是非常高效的,但如果 List 拥有大量元素,移除的元素又在 List 的中间段,那效率相对来说会很低。
linkedList 的获取元素 *** 作实现跟 linkedList 的删除元素 *** 作基本类似,通过分前后半段来循环查找到对应的元素。但是通过这种方式来查询元素是非常低效的,特别是在 for 循环遍历的情况下,每一次循环都会去遍历半个 List。所以在 linkedList 循环遍历时,我们可以使用 iterator 方式迭代循环,直接拿到我们的元素,而不需要通过循环查找 List。
ArrayList 和 linkedList 新增元素 *** 作测试结果 (花费时间):
从集合头部位置新增元素(ArrayList>linkedList)
从集合中间位置新增元素(ArrayList从集合尾部位置新增元素(ArrayList
ArrayList 是数组实现的,而数组是一块连续的内存空间,在添加元素到数组头部的时候,需要对头部以后的数据进行复制重排,所以效率很低;而 linkedList 是基于链表实现,在添加元素的时候,首先会通过循环查找到添加元素的位置,如果要添加的位置处于List 的前半段,就从前往后找;若其位置处于后半段,就从后往前找。因此 linkedList 添加元素到头部是非常高效的。
ArrayList 在添加元素到数组中间时,同样有部分数据需要复制重排,效率也不是很高;linkedList 将元素添加到中间位置,是添加元素最低效率的,因为靠近中间位置,在添加元素之前的循环查找是遍历元素最多的 *** 作。
在添加元素到尾部的 *** 作中,我们发现,在没有扩容的情况下,ArrayList 的效率要高于linkedList。这是因为 ArrayList 在添加元素到尾部的时候,不需要复制重排数据,效率非常高。而 linkedList 虽然也不用循环查找元素,但 linkedList 中多了 new 对象以及变换指针指向对象的过程,所以效率要低于 ArrayList。
这里是基于 ArrayList 初始化容量足够,排除动态扩容数组容量的情况下进行的测试,如果有动态扩容的情况,ArrayList 的效率也会降低。
ArrayList 和 linkedList 遍历元素 *** 作测试结果 (花费时间):
2.4 使用Stream提高遍历集合效率for(; 循环(ArrayList
迭代器迭代循环(ArrayList≈linkedList)
Java8 集合中的 Stream 相当于高级版的 Iterator,他可以通过 Lambda 表达式对集合进行各种非常便利、高效的聚合 *** 作,或者大批量数据 *** 作。
Stream中的 *** 作分为两大类:中间 *** 作和终结 *** 作。中间 *** 作只对 *** 作进行了记录,即只会返回一个流,不会进行计算 *** 作,而终结 *** 作是实现了计算 *** 作。
中间 *** 作又可以分为无状态与有状态 *** 作,前者是指元素的处理不受之前元素的影响,后者是指该 *** 作只有拿到所有元素之后才能继续下去。
终结 *** 作又可以分为短路与非短路 *** 作,前者是指遇到某些符合条件的元素就可以得到最终结果,后者是指必须处理完所有元素才能得到最终结果。
Stram *** 作分类:
看个例子:
Listnames = Arrays.asList(" 张三 ", " 李四 ", " 王老五 ", " 李三 ", " 刘老四 "); String maxLenStartWithZ = names.stream() .filter(name -> name.startsWith(" 张 ")) .mapToInt(String::length) .max() .toString();
上述代码的功能是:查找出一个长度最长,并且以张为姓氏的名字。
Stream 处理数据的方式有两种,串行处理和并行处理。要实现并行处理,只需要在例子的代码中新增一个 Parallel() 方法。示例:
String maxLenStartWithZ = names.stream() .parallel() .filter(name -> name.startsWith(" 张 ")) .mapToInt(String::length) .max() .toString();
行以下几组测试:
多核 CPU 服务器配置环境下,对比长度 100 的 int 数组的性能;
多核 CPU 服务器配置环境下,对比长度 1.00E+8 的 int 数组的性能;
多核 CPU 服务器配置环境下,对比长度 1.00E+8 对象数组过滤分组的性能;
单核 CPU 服务器配置环境下,对比长度 1.00E+8 对象数组过滤分组的性能。
上述几个实验的测试结果:
常规的迭代
Stream 并行迭代 < 常规的迭代 Stream 并行迭代 < 常规的迭代 常规的迭代
可以看到:在循环迭代次数较少的情况下,常规的迭代方式性能反而更好;在单核 CPU 服务器配置环境中,也是常规迭代方式更有优势;而在大数据循环迭代中,如果服务器是多核 CPU 的情况下,Stream 的并行迭代优势明显。所以在平时处理大数据的集合时,应该尽量考虑将应用部署在多核 CPU 环境下,并且使用 Stream 的并行迭代方式进行处理。
在串行处理 *** 作中,Stream 在执行每一步中间 *** 作时,并不会做实际的数据 *** 作处理,而是将这些中间 *** 作串联起来,最终由终结 *** 作触发,生成一个数据处理链表,通过 Java8中的 Spliterator 迭代器进行数据处理;此时,每执行一次迭代,就对所有的无状态的中间 *** 作进行数据处理,而对有状态的中间 *** 作,就需要迭代处理完所有的数据,再进行处理 *** 作;最后就是进行终结 *** 作的数据处理。
在并行处理 *** 作中,Stream 对中间 *** 作基本跟串行处理方式是一样的,但在终结 *** 作中,Stream 将结合 ForkJoin 框架对集合进行切片处理,ForkJoin 框架将每个切片的处理结果Join 合并起来。最后就是要注意 Stream 的使用场景。
在使用 HashMap 时,可以结合自己的场景来设置初始容量和加载因子两个参数。当查询 *** 作较为频繁时,可以适当地减少加载因子;如果对内存利用率要求比较高,可以适当的增加加载因子。
在预知存储数据量的情况下,提前设置初始容量(初始容量 = 预知数据量 / 加载因子)。这样做的好处是可以减少 resize() *** 作,提高 HashMap 的效率。
I/O *** 作分为磁盘 I/O *** 作和网络 I/O *** 作。
JDK1.4 发布了 java.nio 包(new I/O 的缩写),NIO 的发布优化了内存复制以及阻塞导致的严重性能问题。JDK1.7 又发布了 NIO2,提出了从 *** 作系统层面实现的异步 I/O。
在传统 I/O 中,提供了基于流的 I/O 实现,即 InputStream 和OutputStream,这种基于流的实现以字节为单位处理数据。
NIO 与传统 I/O 不同,它是基于块(Block)的,它以块为基本单位处理数据。在 NIO中,最为重要的两个组件是缓冲区(Buffer)和通道(Channel)。Buffer 是一块连续的内存块,是 NIO 读写数据的中转地。Channel 表示缓冲数据的源头或者目的地,它用于读取缓冲或者写入数据,是访问缓冲的接口。
传统 I/O 和 NIO 的最大区别就是传统 I/O 是面向流,NIO 是面向 Buffer。Buffer 可以将文件一次性读入内存再做后续处理,而传统的方式是边读文件边处理数据。虽然传统 I/O后面也使用了缓冲块,例如 BufferedInputStream,但仍然不能和 NIO 相媲美。
NIO 的 Buffer 除了做了缓冲块优化之外,还提供了一个可以直接访问物理内存的类DirectBuffer。普通的 Buffer 分配的是 JVM 堆内存,而 DirectBuffer 是直接分配物理内存。
NIO也称之为 Non-block I/O,即非阻塞 I/O。
传统的 I/O 即使使用了缓冲块,依然存在阻塞问题。由于线程池线程数量有限,一旦发生大量并发请求,超过最大数量的线程就只能等待,直到线程池中有空闲的线程可以被复用。而对 Socket 的输入流进行读取时,读取流会一直阻塞,直到发生以下三种情况的任意一种才会解除阻塞:
有数据可读;
连接释放;
空指针或 I/O 异常。
阻塞问题,就是传统 I/O 最大的弊端。NIO 发布后,通道和多路复用器这两个基本组件实现了 NIO 的非阻塞。
- 通道(Channel)
最开始,在应用程序调用 *** 作系统 I/O 接口时,是由 CPU 完成分配,这种方式最大的问题是“发生大量 I/O 请求时,非常消耗 CPU“;之后, *** 作系统引入了 DMA(直接存储器存储),内核空间与磁盘之间的存取完全由 DMA 负责,但这种方式依然需要向 CPU 申请权限,且需要借助 DMA 总线来完成数据的复制 *** 作,如果 DMA 总线过多,就会造成总线冲突。
通道的出现解决了以上问题,Channel 有自己的处理器,可以完成内核空间和磁盘之间的I/O *** 作。在 NIO 中,我们读取和写入数据都要通过 Channel,由于 Channel 是双向的,所以读、写可以同时进行。 - 多路复用器(Selector)
Selector 是 Java NIO 编程的基础。用于检查一个或多个 NIO Channel 的状态是否处于可读、可写。
Selector 是基于事件驱动实现的,我们可以在 Selector 中注册 accpet、read 监听事件,Selector 会不断轮询注册在其上的 Channel,如果某个 Channel 上面发生监听事件,这个Channel 就处于就绪状态,然后进行 I/O *** 作。
一个线程使用一个 Selector,通过轮询的方式,可以监听多个 Channel 上的事件。我们可以在注册 Channel 时设置该通道为非阻塞,当 Channel 上没有 I/O *** 作时,该线程就不会一直等待了,而是会不断轮询所有 Channel,从而避免发生阻塞。
目前 *** 作系统的 I/O 多路复用机制都使用了 epoll,相比传统的 select 机制,epoll 没有最大连接句柄 1024 的限制。所以 Selector 在理论上可以轮询成千上万的客户端。
JDK 提供的两个输入、输出流对象ObjectInputStream和ObjectOutputStream,它们只能对实现了 Serializable 接口的类的对象进行反序列化和序列化。
JDK自带的序列化有一些缺点:
- 1、无法跨语言
- 2、易被攻击
对于需要长时间进行反序列化的对象,不需要执行任何代码,也可以发起一次攻击。攻击者可以创建循环对象链,然后将序列化后的对象传输到程序中反序列化,这种情况会导致hashCode 方法被调用次数呈次方爆发式增长, 从而引发栈溢出异常。 - 3、序列化后的流太大
序列化后的二进制流大小能体现序列化的性能。序列化后的二进制数组越大,占用的存储空间就越多,存储硬件的成本就越高。如果我们是进行网络传输,则占用的带宽就更多,这时就会影响到系统的吞吐量。 - 4、序列化性能太差
由于以上种种原因,常常使用JSON框架来进行序列化与反序列化,比如:FastJson、Protobuf、Kryo。
2.8 优化RPC网络通信 个人理解,微服务的核心是远程通信和服务治理。远程通信提供了服务之间通信的桥梁,服务治理则提供了服务的后勤保障。
很多微服务框架中的服务通信是基于 RPC 通信实现的,在没有进行组件扩展的前提下,SpringCloud 是基于 Feign 组件实现的 RPC 通信(基于 Http+Json 序列化实现),Dubbo 是基于 SPI 扩展了很多 RPC 通信框架,包括 RMI、Dubbo、Hessian 等 RPC 通信框架(默认是 Dubbo+Hessian 序列化)。
RPC 通信可以支持抢购类的高并发,在这个业务场景中,请求的特点是瞬时高峰、请求量大和传入、传出参数数据包较小。Dubbo 中的 Dubbo 协议就很好地支持了这个请求。
架构演变史:
无论是微服务、SOA、还是 RPC 架构,它们都是分布式服务架构,都需要实现服务之间的互相通信,通常把这种通信统称为 RPC 通信。
RPC(Remote Process Call),即远程服务调用,是通过网络请求远程计算机程序服务的通信技术。RPC 框架封装好了底层网络通信、序列化等技术,我们只需要在项目中引入各个服务的接口包,就可以实现在代码中调用 RPC 服务同调用本地方法一样。
- RMI:JDK 自带的 RPC 通信框架
RMI(Remote Method Invocation)是 JDK 中最先实现了 RPC 通信的框架之一。
RMI 远程代理对象是 RMI 中最核心的组件,除了对象本身所在的虚拟机,其它虚拟机也可以调用此对象的方法。而且这些虚拟机可以不在同一个主机上,通过远程代理对象,远程应用可以用网络协议与服务进行通信。
整个 RMI 的通信过程:
RMI 在高并发场景下的性能瓶颈:
- 1、由于使用Java 默认序列化,性能不是很好。
- 2、RMI 是基于 TCP 短连接实现,在高并发情况下,大量请求会带来大量连接的创建和销毁,性能不好。
- 3、阻塞式网络 I/O。在高并发场景下基于短连接实现的网络通信就很容易产生 I/O 阻塞,性能将会大打折扣。
SpringCloud 是基于 Http通信协议(短连接)和 Json 序列化实现的,在高并发场景下并没有优势。
RPC 通信包括了建立通信、实现报文、传输协议以及传输数据编解码等 *** 作,不同的阶段有不同的优化方式。
- 1、选择合适的通信协议(UDP和TCP)
网络传输协议有 TCP、UDP 协议,这两个协议都是基于 Socket 编程接口之上,为某类应用场景而扩展出的传输协议。
基于 TCP 协议实现的 Socket 通信是有连接的,而传输数据是要通过三次握手来实现数据传输的可靠性,且传输数据是没有边界的,采用的是字节流模式。
基于 UDP 协议实现的 Socket 通信,客户端不需要建立连接,只需要创建一个套接字发送数据报给服务端,这样就不能保证数据报一定会达到服务端,所以在传输数据方面,基于UDP 协议实现的 Socket 通信具有不可靠性。UDP 发送的数据采用的是数据报模式,每个UDP 的数据报都有一个长度,该长度将与数据一起发送到服务端。 - 2、使用长连接
长连接,可以省去大量的 TCP 建立和关闭连接的 *** 作,从而减少系统的性能消耗,节省时间。 - 3、优化 Socket 通信
可以使用比较成熟的通信框架,比如 Netty。 - 4、量身定做报文格式
我们需要设计一套报文,用于描述具体的校验、 *** 作、传输数据等内容。为了提高传输的效率,我们可以根据自己的业务和架构来考虑设计,尽量实现报体小、满足功能、易解析等特性。示例:
- 5、选用合适的序列化框架
- 6、调整 Linux 的 TCP 参数设置选项
如果 RPC 是基于 TCP 短连接实现的,可以通过修改 Linux TCP 配置项来优化网络通信。
三次握手:
四次挥手:
可以通过sysctl -a | grep net.xxx命令运行查看 Linux 系统默认的的 TCP 参数设置,如果需要修改某项配置,可以通过编辑 vim/etc/sysctl.conf,加入需要修改的配置项, 并通过sysctl -p命令运行生效修改后的配置项设置。
通常会通过修改以下几个配置项来提高网络吞吐量和降低延时:
Tomcat 中经常被提到的一个调优就是修改线程的 I/O 模型。Tomcat 8.5 版本之前,默认情况下使用的是 BIO 线程模型,如果在高负载、高并发的场景下,可以通过设置 NIO 线程模型,来提高系统的网络通信性能。
Tomcat 在 I/O 读写 *** 作比较多的情况下,使用 NIO 线程模型有明显的优势。
*** 作系统内核的网络模型衍生出了五种 I/O 模型:阻塞式I/O、非阻塞式 I/O、I/O 复用、信号驱动式 I/O 和异步 I/O。
最开始的阻塞式 I/O,它在每一个连接创建时,都需要一个用户线程来处理,并且在 I/O *** 作没有就绪或结束时,线程会被挂起,进入阻塞等待状态,阻塞式 I/O 就成为了导致性能瓶颈的根本原因。
TCP 连接是最常用的,一起来了解下 TCP 服务端的工作流程(由于TCP 的数据传输比较复杂,存在拆包和装包的可能,这里我只假设一次最简单的 TCP 数据传输):
首先,应用程序通过系统调用 socket 创建一个套接字,它是系统分配给应用程序的一个文件描述符;
其次,应用程序会通过系统调用 bind,绑定地址和端口号,给套接字命名一个名称;
然后,系统会调用 listen 创建一个队列用于存放客户端进来的连接;
最后,应用服务会通过系统调用 accept 来监听客户端的连接请求。
当有一个客户端连接到服务端之后,服务端就会调用 fork 创建一个子进程,通过系统调用read 监听客户端发来的消息,再通过 write 向客户端返回信息。
- 基于线程模型的 Tomcat 参数调优
Tomcat 中,BIO、NIO 是基于主从 Reactor 线程模型实现的。
在BIO中,Tomcat 中的 Acceptor 只负责监听新的连接,一旦连接建立监听到 I/O *** 作,将会交给 Worker 线程中,Worker 线程专门负责 I/O 读写 *** 作。
在NIO中,Tomcat 新增了一个 Poller 线程池,Acceptor 监听到连接后,不是直接使用Worker 中的线程处理请求,而是先将请求发送给了 Poller 缓冲队列。在 Poller 中,维护了一个 Selector 对象,当 Poller 从队列中取出连接后,注册到该 Selector 中;然后通过遍历 Selector,找出其中就绪的 I/O *** 作,并使用 Worker 中的线程处理相应的请求。
可以通过以下几个参数来设置 Acceptor 线程池和 Worker 线程池的配置项:
acceptorThreadCount:该参数代表 Acceptor 的线程数量,在请求客户端的数据量非常巨大的情况下,可以适当地调大该线程数量来提高处理请求连接的能力,默认值为 1。
maxThreads:专门处理 I/O *** 作的 Worker 线程数量,默认是 200,可以根据实际的环境来调整该参数,但不一定越大越好。
acceptCount: Tomcat 的 Acceptor 线程是负责从 accept 队列中取出该 connection,然后交给工作线程去执行相关 *** 作,这里的 acceptCount 指的是 accept 队列的大小。当 Http 关闭 keep alive,在并发量比较大时,可以适当地调大这个值。而在 Http 开启keep alive 时,因为 Worker 线程数量有限,Worker 线程就可能因长时间被占用,而连接在 accept 队列中等待超时。如果 accept 队列过大,就容易浪费连接。
maxConnections:表示有多少个 socket 连接到 Tomcat 上。在 BIO 模式中,一个线程只能处理一个连接,一般 maxConnections 与 maxThreads 的值大小相同;在 NIO 模式中,一个线程同时处理多个连接,maxConnections 应该设置得比 maxThreads 要大的多,默认是 10000。
Synchronized 是基于底层 *** 作系统的 Mutex Lock实现的,每次获取和释放锁 *** 作都会带来用户态和内核态的切换,从而增加系统性能开销。
Synchronized 在修饰同步代码块时,是由 monitorenter和monitorexit 指令来实现同步的。进入 monitorenter 指令后,线程将持有 Monitor 对象,退出 monitorenter 指令后,线程将释放该 Monitor 对象。
当 Synchronized 修饰同步方法时,并没有发现 monitorenter 和 monitorexit 指令,而是出现了一个 ACC_SYNCHRonIZED 标志。JVM 使用了ACC_SYNCHRonIZED 访问标志来区分一个方法是否是同步方法。
当方法调用时,调用指令将会检查该方法是否被设置 ACC_SYNCHRonIZED 访问标志。如果设置了该标志,执行线程将先持有 Monitor 对象,然后再执行方法。在该方法运行期间,其它线程将无法获取到该 Mointor 对象,当方法执行完成后,再释放该 Monitor 对
象。
在高并发场景下,当大量线程同时竞争同一个锁资源时,偏向锁就会被撤销,发生stop the word 后, 开启偏向锁无疑会带来更大的性能开销,这时我们可以通过添加 JVM参数关闭偏向锁来调优系统性能,示例:
-XX:-UseBiasedLocking // 关闭偏向锁(默认打开) -XX:+UseHeavyMonitors // 设置重量级锁
在锁竞争不激烈且锁占用时间非常短的场景下,自旋锁可以提高系统性能。一旦锁竞争激烈或锁占用的时间过长,自旋锁将会导致大量的线程一直处于 CAS 重试状态,占用 CPU 资源,反而会增加系统性能开销。所以自旋锁和重量级锁的使用都要结合实际场景。
在高负载、高并发的场景下,我们可以通过设置 JVM 参数来关闭自旋锁,优化系统性能,示例:
-XX:-UseSpinning // 参数关闭自旋锁优化 (默认打开) -XX:PreBlockSpin // 参数修改默认的自旋次数。JDK1.7 后,去掉此参数,由 jvm 控制
还可以通过代码层来实现锁优化,减小锁粒度就是一种惯用的方法。
当锁对象是一个数组或队列时,集中竞争一个对象的话会非常激烈,锁也会升级为重量级锁。我们可以考虑将一个数组和队列对象拆成多个小对象,来降低锁竞争,提升并行度。
最经典的减小锁粒度的案例就是 JDK1.8 之前实现的 ConcurrentHashMap 版本。我们知道,HashTable 是基于一个数组 + 链表实现的,所以在并发读写 *** 作集合时,存在激烈的锁资源竞争,也因此性能会存在瓶颈。而 ConcurrentHashMap 就很很巧妙地使用了分段锁 Segment 来降低锁资源竞争,图示:
减少锁竞争,是优化 Synchronized 同步锁的关键。我们应该尽量使 Synchronized 同步锁处于轻量级锁或偏向锁,这样才能提高 Synchronized 同步锁的性能;通过减小锁粒度来降低锁竞争也是一种最常用的优化方法;另外我们还可以通过减少锁的持有时间来提高Synchronized 同步锁在自旋时获取锁资源的成功率,避免 Synchronized 同步锁升级为重量级锁。
Lock 同步锁(以下简称 Lock 锁)需要的是显示获取和释放锁,这就为获取和释放锁提供了更多的灵活性。
不管使用 Synchronized 同步锁还是 Lock 同步锁,只要存在锁竞争就会产生线程阻塞,从而导致线程之间的频繁切换,最终增加性能消耗。因此,如何降低锁竞争,就成为了优化锁的关键。
在 Synchronized 同步锁中,我们了解了可以通过减小锁粒度、减少锁占用时间来降低锁的竞争。在Lock中,可以利用 Lock 锁的灵活性,通过锁分离的方式来降低锁竞争。比如,Lock 锁实现了读写锁分离来优化读大于写的场景。
乐观锁相比悲观锁来说,不会带来死锁、饥饿等活性故障问题,线程间的相互影响也远远比悲观锁要小。同时,乐观锁没有因竞争造成的系统开销,所以在性能上
也是更好。
CAS 是实现乐观锁的核心算法。
CAS 是调用处理器底层指令来实现原子 *** 作。处理器和物理内存之间的通信速度要远慢于处理器间的处理速度,所以处理器有自己的内部缓存。
在执行 *** 作时,频繁使用的内存数据会缓存在处理器的 L1、L2 和L3 高速缓存中,以加快频繁读取的速度。图示:
乐观锁在并发性能上要比悲观锁优越,但是在写大于读的 *** 作场景下,CAS 失败的可能性会增大,如果不放弃此次CAS *** 作,就需要循环做 CAS 重试,这无疑会长时间地占用 CPU。
在 JDK1.8 中,Java 提供了一个新的原子类 LongAdder。LongAdder 在高并发场景下会比 AtomicInteger 和AtomicLong 的性能更好,代价就是会消耗更多的内存空间。
LongAdder 的原理就是降低 *** 作共享变量的并发数,也就是将对单一共享变量的 *** 作压力分散到多个变量值上,将竞争的每个写线程的 value 值分散到一个数组中,不同线程会命中到数组的不同槽中,各个线程只对自己槽中的 value 值进行 CAS *** 作,最后在读取值的时候会将原子 *** 作的共享变量与各个分散在数组的 value 值相加,返回一个近似准确的数值。
在日常开发中,使用乐观锁最常见的场景就是数据库的更新 *** 作了。为了保证 *** 作数据库的原子性,我们常常会为每一条数据定义一个版本号,并在更新前获取到它,到了更新数据库的时候,还要判断下已经获取的版本号是否被更新过,如果没有,则执行该 *** 作。
在并发程序中,并不是启动更多的线程就能让程序最大限度地并发执行。线程数量设置太小,会导致程序不能充分地利用系统资源;线程数量设置太大,又可能带来资源的过度竞争,导致上下文切换带来额外的系统开销。
线程的生命周期:
上下文切换可以分为两种:一种是程序本身触发的切换,称为自发性上下文切换;另一种是由系统或者虚拟机诱发的非自发性上下文切换。
在多线程编程中,执行调用以下方法或关键字,常常就会引发自发性上下文切换:
sleep()
wait()
yield()
join()
park()
synchronized
lock
非自发性上下文切换指线程由于调度器的原因被迫切出。常见的有:线程被分配的时间片用完,虚拟机垃圾回收导致或者执行优先级的问题导致。
在 Linux 系统下,可以使用 Linux 内核提供的 vmstat 命令,来监视 Java 程序运行过程中系统的上下文切换频率,图示:
如果是监视某个应用的上下文切换,就可以使用 pidstat 命令监控指定进程的 Context Switch 上下文切换:
优化多线程上下文切换的几种方法:
- 1、竞争锁优化
在多线程编程中,锁其实不是性能开销的根源,竞争锁才是。锁的优化归根结底就是减少竞争。一些具体的方法:
- 减少锁的持有时间
- 降低锁的粒度
可以考虑将锁粒度拆分得更小一些,以此避免所有线程对一个锁资源的竞争过于激烈。具体方式有以下两种:锁分离和锁分段。
读写锁实现了锁分离,也就是说读写锁是由“读锁”和“写锁”两个锁实现的,其规则是可以共享读,但只有一个写。
ConcurrentHashMap就使用了锁分段。 - 非阻塞乐观锁替代竞争锁
volatile 关键字的作用是保障可见性及有序性,volatile 的读写 *** 作不会导致上下文切换,因此开销比较小。
- 2、wait/notify 优化
Object.notify() 能满足需求时,就用 Object.notify() 替代 Object.notifyAll()。
其次,在生产者执行完 Object.notify() / notifyAll() 唤醒其它线程之后,应该尽快地释放内部锁。
建议使用 Lock 锁结合 Condition 接口替代 Synchronized 内部锁中的 wait /notify,实现等待/通知。这样做不仅可以解决上述的 Object.wait(long) 无法区分的问题,还可以解决线程被过早唤醒的问题。
Condition 接口定义的 await 方法 、signal 方法和 signalAll 方法分别相当于Object.wait()、 Object.notify() 和 Object.notifyAll()。
- 3、合理地设置线程池大小,避免创建过多线程
线程池的线程数量设置不宜过大,因为一旦线程池的工作线程总数超过系统所拥有的处理器数量,就会导致过多的上下文切换。 - 4、减少 Java 虚拟机的垃圾回收
切忌在并发场景下使用 HashMap。因为在 JDK1.7 之前,在并发场景下使用 HashMap 会出现死循环,从而导致 CPU 使用率居高不下,而扩容是导致死循环的主要原因。虽然 Java 在 JDK1.8 中修复了 HashMap 扩容导致的死循环问题,但在高并发场景下,依然会有数据丢失以及不准确的情况出现。
要在并发环境下,选择Map实现类时,可以选择ConcurrentHashMap。
虽然 ConcurrentHashMap 的整体性能要优于 Hashtable,但在某些场景中,ConcurrentHashMap 依然不能代替 Hashtable。例如,在强一致的场景中ConcurrentHashMap 就不适用,原因是 ConcurrentHashMap 中的 get、size 等方法没有用到锁,ConcurrentHashMap 是弱一致性的,因此有可能会导致某次读无法马上获取到写入的数据。
如果对数据有强一致要求,则需使用 Hashtable;在大部分场景通常都是弱一致性的情况下,使用 ConcurrentHashMap 即可;如果数据量在千万级别,且存在大量增删改 *** 作,则可以考虑使用 ConcurrentSkipListMap。
一般多线程执行的任务类型可以分为 CPU 密集型和 I/O 密集型。
- CPU 密集型任务
这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。 - I/O 密集型任务
这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。
一些常规的业务 *** 作,比如,通过一个线程池实现向用户定时推送消息的业务,线程池的数量设置示例:
线程数 =N(CPU 核数)*(1+WT(线程等待时间)/ST(线程时间运行时间))
综合来看,可以根据自己的业务场景,从“N+1”和“2N”两个公式中选出一个适合的,计算出一个大概的线程数量,之后通过实际压测,逐渐往“增大线程数量”和“减小线程数量”这两个方向调整,然后观察整体的处理时间变化,最终确定一个具体的线程数量。
3.7 用协程来优化多线程业务实现线程主要有三种方式:轻量级进程和内核线程一对一相互映射实现的 1:1 线程模型、用户线程和内核线程实现的N:1 线程模型以及用户线程和轻量级进程混合实现的 N:M线程模型。
- 1:1 线程模型
在Linux *** 作系统编程中,往往都是通过 fork() 函数创建一个子进程来代表一个内核中的线程。采用 fork() 创建子进程的方式来实现并行运行,会产生大量冗余数据,即占用大量内存空间,又消耗大量 CPU 时间用来初始化内存空间以及复制数据。
如果是一份一样的数据,为什么不共享主进程的这一份数据呢?这时候轻量级进程(Light Weight Process,即LWP)出现了。
相对于 fork() 系统调用创建的线程来说,LWP 使用 clone()系统调用创建线程,该函数是将部分父进程的资源的数据结构进行复制,复制内容可选,且没有被复制的资源可以通过指针共享给子进程。因此,轻量级进程的运行单元更小,运行速度更快。LWP 是跟内核线程一对一映射的,每个 LWP都是由一个内核线程支持。 - N:1 线程模型
1:1 线程模型由于跟内核是一对一映射,所以在线程创建、切换上都存在用户态和内核态的切换,性能开销比较大。除此之外,它还存在局限性,主要就是指系统的资源有限,不能支持创建大量的 LWP。N:1 线程模型就可以很好地解决 1:1 线程模型的这两个问题。
该线程模型是在用户空间完成了线程的创建、同步、销毁和调度,已经不需要内核的帮助了,也就是说在线程创建、同步、销毁的过程中不会产生用户态和内核态的空间切换,因此线程的 *** 作非常快速且低消耗。 - N:M 线程模型
N:1 线程模型的缺点在于 *** 作系统不能感知用户态的线程,因此容易造成某一个线程进行系统调用内核线程时被阻塞,从而导致整个进程被阻塞。
N:M 线程模型是基于上述两种线程模型实现的一种混合线程管理模型,即支持用户态线程通过 LWP 与内核线程连接,用户态的线程数量和内核态的 LWP 数量是 N:M 的映射关系。
目前 Java 在 Linux *** 作系统下采用的是用户线程加轻量级线程,一个用户线程映射到一个内核线程,即 1:1 线程模型。由于线程是通过内核调度,从一个线程切换到另一个线程就涉及到了上下文切换。
Go 语言是使用了 N:M 线程模型实现了自己的调度器,它在 N 个内核线程上多路复用(或调度)M 个协程,协程的上下文切换是在用户态由协程调度器完成的,因此不需要陷入内核,相比之下,这个代价就很小了。
相比线程,协程少了由于同步资源竞争带来的 CPU 上下文切换,I/O 密集型的应用比较适合使用,特别是在网络请求中,有较多的时间在等待后端响应,协程可以保证线程不会阻塞在等待网络响应中,充分利用了多核多线程的能力。而对于 CPU 密集型的应用,由于在多数情况下 CPU 都比较繁忙,协程的优势就不是特别明显了。
目前 Java 原生语言还不支持协程。
目前 Kilim 协程框架在 Java 中应用得比较多,通过这个框架,开发人员就可以低成本地在 Java 中使用协程。
在有严重阻塞的场景下,协程的性能更胜一筹。其实,I/O 阻塞型场景也就是协程在 Java 中的主要应用。
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)