在项目开发的初期,有必要过于在意性能优化,这样反而会疲于性能优化,不仅不会给系统性能带来提升,还会影响到开发进度,甚至获得相反的效果,给系统带来新的问题。
只需要在代码层面保证有效的编码,比如,减少磁盘I/O操作、降低竞争锁的使用以及使用高效的算法等等。遇到比较复杂的业务,可以充分利用设计模式来优化业务代码。
在系统编码完成之后,就可以对系统进行性能测试了。这时候,产品经理一般会提供线上预期数据,然后在提供的参考平台上进行压测,通过性能分析、统计工具来统计各项性能指标,看是否在预期范围之内。
在项目成功上线后,我们还需要根据线上的实际情况,依照日志监控以及性能统计日志,来观测系统性能问题,一旦发现问题,就要对日志进行分析并及时修复问题。
可能成为系统的性能瓶颈的计算机资源:
衡量一般系统的性能的指标:
数据库响应时间
:数据库操作所消耗的时间,往往是整个请求链中最耗时的;服务端响应时间
:服务端包括Nginx分发的请求所消耗的时间以及服务端程序执行所消耗的时间;网络响应时间
:这是网络传输时,网络硬件需要对传输的请求进行解析等操作所消耗的时间;客户端响应时间
:对于普通的Web、App客户端来说,消耗时间是可以忽略不计的,但如果你的客户端嵌入了大量的逻辑处理,消耗的时间就有可能变长,从而成为系统的瓶颈。IOPS(Input/Output Per Second)
数据吞吐量
网络吞吐量
:指网络传输时没有帧丢失的情况下,设备能够接受的最大数据
速率。网络吞吐量不仅仅跟带宽有关系,还跟CPU的处理能力、网卡、防火墙、外部接口以及I/O等紧密关联。而吞吐量的大小主要由网卡的处理能力、内部程序算法以及带宽大小决定。
面对日渐复杂的系统,制定合理的性能测试,可以提前发现性能瓶颈,然后有针对性地制定调优策略。总结一下就是“测试 - 分析 - 调优”
三步走。
性能测试是提前发现性能瓶颈,保障系统性能稳定的必要措施。
两种常用的测试方法:微基准性能测试和宏基准性能测试。
在做性能测试时,还要注意的一些问题:
从应用层到操作系统层的几种调优策略:
为了保证系统的稳定性,我们还需要采用一些兜底策略。示例:
目前很多公司使用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对象不可被更改。这样做的好处:
String对象的优化方式:
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存储的数据越大,遍历的时间复杂度就会增加。如果数据过大,会增加整个字符串常量池的负担。
构造正则表达式语法的元字符,由普通字符、标准字符、限定字符(量词)、定位字符(边界字符)组成:
正则表达式的优化方法:
贪婪模式
:在数量匹配中,如果单独使用 +、 ? 、* 或{min,max} 等量词,正则表达式会匹配尽可能多的内容。示例: regex = "ab{1,3}c"
懒惰模式
:正则表达式会尽可能少地重复匹配字符。如果匹配成功,它会继续匹配剩余的字符串。例如,在上面例子的字符后面加一个“?”,就可以开启懒惰模式。示例:
regex = "ab{1,3}?c"
独占模式
:一样会最大限度地匹配更多内容;不同的是,在独占模式下,匹配失败就会结束匹配,不会发生回溯问题。还是上边的例子,在字符后面加一个“+”,就可以开启独占模式。示例:
regex = "ab{1,3}+bc"
以往的经验来看,如果使用正则表达式能使代码简洁方便,那么在做好性能排查的前提下,可以去使用;如果不能,那么正则表达式能不用就不用,以此避免造成更多的性能问题。
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遍历元素操作测试结果 (花费时间):
for循环(ArrayList
迭代器迭代循环(ArrayList≈LinkedList)
Java8集合中的Stream相当于高级版的Iterator,他可以通过Lambda表达式对集合进行各种非常便利、高效的聚合操作,或者大批量数据操作。
Stream中的操作分为两大类:中间操作和终结操作。中间操作只对操作进行了记录,即只会返回一个流,不会进行计算操作,而终结操作是实现了计算操作。
中间操作又可以分为无状态与有状态操作,前者是指元素的处理不受之前元素的影响,后者是指该操作只有拿到所有元素之后才能继续下去。
终结操作又可以分为短路与非短路操作,前者是指遇到某些符合条件的元素就可以得到最终结果,后者是指必须处理完所有元素才能得到最终结果。
Stram操作分类:
看个例子:
List<String> names = 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串行迭代
Strea 并行迭代 < 常规的迭代 < Stream串行迭代
Stream并行迭代 < 常规的迭代 < Stream串行迭代
常规的迭代 < 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的非阻塞。
JDK提供的两个输入、输出流对象ObjectInputStream和ObjectOutputStream,它们只能对实现了Serializable接口的类的对象进行反序列化和序列化。
JDK自带的序列化有一些缺点:
由于以上种种原因,常常使用JSON框架来进行序列化与反序列化,比如:FastJson、Protobuf、Kryo。
微服务的核心是远程通信和服务治理。远程通信提供了服务之间通信的桥梁,服务治理则提供了服务的后勤保障。
很多微服务框架中的服务通信是基于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服务同调用本地方法一样。
- 1、由于使用Java默认序列化,性能不是很好。
- 2、RMI是基于TCP短连接实现,在高并发情况下,大量请求会带来大量连接的创建和销毁,性能不好。
- 3、阻塞式网络I/O。在高并发场景下基于短连接实现的网络通信就很容易产生I/O阻塞,性能将会大打折扣。
SpringCloud是基于Http通信协议(短连接)和Json序列化实现的,在高并发场景下并没有优势。
RPC通信包括了建立通信、实现报文、传输协议以及传输数据编解码等操作,不同的阶段有不同的优化方式。
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向客户端返回信息。
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程序运行过程中系统的上下文切换频率,图示:
vmstat
命令是最常见的Linux/Unix监控工具,可以展现给定时间间隔的服务器的状态值,包括服务器的CPU使用率,内存使用,虚拟内存交换情况,IO读写情况。
一般vmstat工具的使用是通过两个数字参数来完成的,第一个参数是采样的时间间隔数,单位是秒,第二个参数是采样的次数,示例:
root@ubuntu:~# vmstat 2 1
procs -----------memory---------- ---swap-- -----io---- -system-- ----cpu----
r b swpd free buff cache si so bi bo in cs us sy id wa
1 0 0 3498472 315836 3819540 0 0 0 1 2 0 0 0 100 0
2表示每个两秒采集一次服务器状态,1表示只采集一次。
如果是监视某个应用的上下文切换,就可以使用pidstat命令监控指定进程的Context Switch上下文切换:
pidstat主要用于监控全部或指定进程占用系统资源的情况,如CPU,内存、设备IO、任务切换、线程等。pidstat首次运行时显示自系统启动开始的各项统计信息,之后运行pidstat将显示自上次运行该命令以后的统计信息。用户可以通过指定统计的次数和时间来获得所需的统计信息。
执行pidstat,将输出系统启动后所有活动进程的cpu统计信息,示例:
linux:~ # pidstat
Linux 2.6.32.12-0.7-default (linux) 06/18/12 _x86_64_
11:37:19 PID %usr %system %guest %CPU CPU Command
……
11:37:19 11452 0.00 0.00 0.00 0.00 2 bash
11:37:19 11509 0.00 0.00 0.00 0.00 3 dd
优化多线程上下文切换的几种方法:
减少锁的持有时间
降低锁的粒度
非阻塞乐观锁替代竞争锁
Condition接口定义的await方法 、signal方法和signalAll方法分别相当于Object.wait()、 Object.notify()和 Object.notifyAll()。
切忌在并发场景下使用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密集型。
一些常规的业务操作,比如,通过一个线程池实现向用户定时推送消息的业务,线程池的数量设置示例:
线程数 =N(CPU 核数)*(1+WT(线程等待时间)/ST(线程时间运行时间))
综合来看,可以根据自己的业务场景,从“N+1”和“2N”两个公式中选出一个适合的,计算出一个大概的线程数量,之后通过实际压测,逐渐往“增大线程数量”和“减小线程数量”这两个方向调整,然后观察整体的处理时间变化,最终确定一个具体的线程数量。
实现线程主要有三种方式:轻量级进程和内核线程一对一相互映射实现的1:1线程模型、用户线程和内核线程实现的N:1线程模型以及用户线程和轻量级进程混合实现的N:M线程模型。
目前Java在Linux操作系统下采用的是用户线程加轻量级线程,一个用户线程映射到一个内核线程,即1:1线程模型。由于线程是通过内核调度,从一个线程切换到另一个线程就涉及到了上下文切换。
Go语言是使用了N:M线程模型实现了自己的调度器,它在N个内核线程上多路复用(或调度)M个协程,协程的上下文切换是在用户态由协程调度器完成的,因此不需要陷入内核,相比之下,这个代价就很小了。
相比线程,协程少了由于同步资源竞争带来的CPU上下文切换,I/O密集型的应用比较适合使用,特别是在网络请求中,有较多的时间在等待后端响应,协程可以保证线程不会阻塞在等待网络响应中,充分利用了多核多线程的能力。而对于CPU密集型的应用,由于在多数情况下CPU都比较繁忙,协程的优势就不是特别明显了。
目前Java原生语言还不支持协程。
目前Kilim协程框架在Java中应用得比较多,通过这个框架,开发人员就可以低成本地在Java中使用协程。
在有严重阻塞的场景下,协程的性能更胜一筹。其实,I/O阻塞型场景也就是协程在Java中的主要应用。