《Java性能调优实战》笔记(一)Java编程性能调优、多线程性能优化

文章目录

    • 一、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 用协程来优化多线程业务

一、Java性能调优概述

1.1 性能调优标准

  在项目开发的初期,有必要过于在意性能优化,这样反而会疲于性能优化,不仅不会给系统性能带来提升,还会影响到开发进度,甚至获得相反的效果,给系统带来新的问题。
  只需要在代码层面保证有效的编码,比如,减少磁盘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、响应时间
      响应时间是衡量系统性能的重要指标之一,响应时间越短,性能越好,一般一个接口的响应时间是在毫秒级。可以把响应时间自下而上细分为以下几种:
    《Java性能调优实战》笔记(一)Java编程性能调优、多线程性能优化_第1张图片
      数据库响应时间:数据库操作所消耗的时间,往往是整个请求链中最耗时的;
      服务端响应时间:服务端包括Nginx分发的请求所消耗的时间以及服务端程序执行所消耗的时间;
      网络响应时间:这是网络传输时,网络硬件需要对传输的请求进行解析等操作所消耗的时间;
      客户端响应时间:对于普通的Web、App客户端来说,消耗时间是可以忽略不计的,但如果你的客户端嵌入了大量的逻辑处理,消耗的时间就有可能变长,从而成为系统的瓶颈。
  • 2、吞吐量
      我们往往会比较注重系统接口的TPS(每秒事务处理量),因为TPS体现了接口的性能,TPS越大,性能越好。在系统中,我们也可以把吞吐量自下而上地分为两种:磁盘吞吐量和网络吞吐量。
      磁盘性能有两个关键衡量指标:
  1. IOPS(Input/Output Per Second)
      每秒的输入输出量(或读写次数),这种是指单位时间内系统能处理的I/O请求数量,I/O请求通常为读或写数据操作请求,关注的是随机读写性能。适应于随机读写频繁的应用,如小文件存储(图片)、OLTP数据库、邮件服务器。
  2. 数据吞吐量
      指单位时间内可以成功传输的数据量。对于大量顺序读写频繁的应用,传输大量连续数据,例如,电视台的视频编辑、视频点播VOD(Video On Demand),数据吞吐量则是关键衡量指标。

  网络吞吐量:指网络传输时没有帧丢失的情况下,设备能够接受的最大数据
速率。网络吞吐量不仅仅跟带宽有关系,还跟CPU的处理能力、网卡、防火墙、外部接口以及I/O等紧密关联。而吞吐量的大小主要由网卡的处理能力、内部程序算法以及带宽大小决定。

  • 3、计算机资源分配使用率
      通常由CPU占用率、内存使用率、磁盘I/O、网络I/O来表示资源使用率。
  • 4、负载承受能力
      当系统压力上升时,你可以观察,系统响应时间的上升曲线是否平缓。这项指标能直观地反馈给你,系统所能承受的负载压力极限。

1.2 制定性能调优策略

  面对日渐复杂的系统,制定合理的性能测试,可以提前发现性能瓶颈,然后有针对性地制定调优策略。总结一下就是“测试 - 分析 - 调优”三步走。
  性能测试是提前发现性能瓶颈,保障系统性能稳定的必要措施。
  两种常用的测试方法:微基准性能测试和宏基准性能测试。

  • 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服务。
  调优策略简单总结:
《Java性能调优实战》笔记(一)Java编程性能调优、多线程性能优化_第2张图片

二、Java编程性能调优

2.1 字符串

  String对象优化过程:
《Java性能调优实战》笔记(一)Java编程性能调优、多线程性能优化_第3张图片
  在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存储的数据越大,遍历的时间复杂度就会增加。如果数据过大,会增加整个字符串常量池的负担。

2.2 正则表达式

  构造正则表达式语法的元字符,由普通字符、标准字符、限定字符(量词)、定位字符(边界字符)组成:
《Java性能调优实战》笔记(一)Java编程性能调优、多线程性能优化_第4张图片
  正则表达式的优化方法:

  • 1、 少用贪婪模式,多用独占模式
      贪婪模式:在数量匹配中,如果单独使用 +、 ? 、* 或{min,max} 等量词,正则表达式会匹配尽可能多的内容。示例:
	regex = "ab{1,3}c"

  懒惰模式:正则表达式会尽可能少地重复匹配字符。如果匹配成功,它会继续匹配剩余的字符串。例如,在上面例子的字符后面加一个“?”,就可以开启懒惰模式。示例:

	regex = "ab{1,3}?c"

  独占模式:一样会最大限度地匹配更多内容;不同的是,在独占模式下,匹配失败就会结束匹配,不会发生回溯问题。还是上边的例子,在字符后面加一个“+”,就可以开启独占模式。示例:

	regex = "ab{1,3}+bc"
  • 2、 减少分支选择
      分支选择类型“(X|Y|Z)”的正则表达式会降低性能,要尽量减少使用。如果一定要用,可以通过以下几种方式来优化:
  1. 需要考虑选择的顺序,将比较常用的选择项放在前面,使它们可以较快地被匹配;
  2. 可以尝试提取共用模式,例如,将“(abcd|abef)”替换为“ab(cd|ef)”,后者匹配速度较快,因为NFA自动机会尝试匹配ab,如果没有找到,就不会再尝试任何选项;
  3. 如果是简单的分支选择类型,我们可以用三次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属性。这样做有以下几点好处:

  1. first/last属性能更清晰地表达链表的链头和链尾概念;
  2. first/last方式可以在初始化LinkedList的时候节省new一个Entry;
  3. 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)

2.4 使用Stream提高遍历集合效率

  Java8集合中的Stream相当于高级版的Iterator,他可以通过Lambda表达式对集合进行各种非常便利、高效的聚合操作,或者大批量数据操作。
  Stream中的操作分为两大类:中间操作和终结操作。中间操作只对操作进行了记录,即只会返回一个流,不会进行计算操作,而终结操作是实现了计算操作。
  中间操作又可以分为无状态与有状态操作,前者是指元素的处理不受之前元素的影响,后者是指该操作只有拿到所有元素之后才能继续下去。
  终结操作又可以分为短路与非短路操作,前者是指遇到某些符合条件的元素就可以得到最终结果,后者是指必须处理完所有元素才能得到最终结果。
  Stram操作分类:
《Java性能调优实战》笔记(一)Java编程性能调优、多线程性能优化_第5张图片
  看个例子:

	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的使用场景。

2.5 HashMap优化

  在使用HashMap时,可以结合自己的场景来设置初始容量和加载因子两个参数。当查询操作较为频繁时,可以适当地减少加载因子;如果对内存利用率要求比较高,可以适当的增加加载因子。
  在预知存储数据量的情况下,提前设置初始容量(初始容量 = 预知数据量 / 加载因子)。这样做的好处是可以减少resize()操作,提高HashMap的效率。

2.6 高并发下I/O优化

  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在理论上可以轮询成千上万的客户端。

2.7 避免使用Java序列化

  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协议就很好地支持了这个请求。
  架构演变史:
《Java性能调优实战》笔记(一)Java编程性能调优、多线程性能优化_第6张图片
  无论是微服务、SOA、还是RPC架构,它们都是分布式服务架构,都需要实现服务之间的互相通信,通常把这种通信统称为RPC通信。
  RPC(Remote Process Call),即远程服务调用,是通过网络请求远程计算机程序服务的通信技术。RPC框架封装好了底层网络通信、序列化等技术,我们只需要在项目中引入各个服务的接口包,就可以实现在代码中调用RPC服务同调用本地方法一样。

  • RMI:JDK自带的RPC通信框架
      RMI(Remote Method Invocation)是JDK中最先实现了RPC通信的框架之一。
      RMI远程代理对象是RMI中最核心的组件,除了对象本身所在的虚拟机,其它虚拟机也可以调用此对象的方法。而且这些虚拟机可以不在同一个主机上,通过远程代理对象,远程应用可以用网络协议与服务进行通信。
      整个RMI的通信过程:
    《Java性能调优实战》笔记(一)Java编程性能调优、多线程性能优化_第7张图片
      RMI在高并发场景下的性能瓶颈:
  • 1、由于使用Java默认序列化,性能不是很好。
  • 2、RMI是基于TCP短连接实现,在高并发情况下,大量请求会带来大量连接的创建和销毁,性能不好。
  • 3、阻塞式网络I/O。在高并发场景下基于短连接实现的网络通信就很容易产生I/O阻塞,性能将会大打折扣。

  SpringCloud是基于Http通信协议(短连接)和Json序列化实现的,在高并发场景下并没有优势。
  RPC通信包括了建立通信、实现报文、传输协议以及传输数据编解码等操作,不同的阶段有不同的优化方式。

  • 1、选择合适的通信协议(UDP和TCP)
      网络传输协议有TCP、UDP协议,这两个协议都是基于Socket编程接口之上,为某类应用场景而扩展出的传输协议。
    《Java性能调优实战》笔记(一)Java编程性能调优、多线程性能优化_第8张图片
      基于TCP协议实现的Socket通信是有连接的,而传输数据是要通过三次握手来实现数据传输的可靠性,且传输数据是没有边界的,采用的是字节流模式。
      基于UDP协议实现的Socket通信,客户端不需要建立连接,只需要创建一个套接字发送数据报给服务端,这样就不能保证数据报一定会达到服务端,所以在传输数据方面,基于UDP协议实现的Socket通信具有不可靠性。UDP发送的数据采用的是数据报模式,每个UDP的数据报都有一个长度,该长度将与数据一起发送到服务端。
  • 2、使用长连接
      长连接,可以省去大量的TCP建立和关闭连接的操作,从而减少系统的性能消耗,节省时间。
  • 3、优化Socket通信
      可以使用比较成熟的通信框架,比如Netty。
  • 4、量身定做报文格式
      我们需要设计一套报文,用于描述具体的校验、操作、传输数据等内容。为了提高传输的效率,我们可以根据自己的业务和架构来考虑设计,尽量实现报体小、满足功能、易解析等特性。示例:

    《Java性能调优实战》笔记(一)Java编程性能调优、多线程性能优化_第9张图片
  • 5、选用合适的序列化框架
  • 6、调整Linux的TCP参数设置选项
      如果RPC是基于TCP短连接实现的,可以通过修改Linux TCP配置项来优化网络通信。
      三次握手:
    《Java性能调优实战》笔记(一)Java编程性能调优、多线程性能优化_第10张图片
      四次挥手:
    《Java性能调优实战》笔记(一)Java编程性能调优、多线程性能优化_第11张图片
      可以通过sysctl -a | grep net.xxx命令运行查看Linux系统默认的的TCP参数设置,如果需要修改某项配置,可以通过编辑vim /etc/sysctl.conf,加入需要修改的配置项, 并通过sysctl -p命令运行生效修改后的配置项设置。
      通常会通过修改以下几个配置项来提高网络吞吐量和降低延时:

2.9 NIO优化

  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数据传输):
《Java性能调优实战》笔记(一)Java编程性能调优、多线程性能优化_第12张图片
  首先,应用程序通过系统调用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。

三、多线程性能优化

3.1 Synchronized优化

  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来降低锁资源竞争,图示:
《Java性能调优实战》笔记(一)Java编程性能调优、多线程性能优化_第13张图片
  减少锁竞争,是优化Synchronized同步锁的关键。我们应该尽量使Synchronized同步锁处于轻量级锁或偏向锁,这样才能提高Synchronized同步锁的性能;通过减小锁粒度来降低锁竞争也是一种最常用的优化方法;另外我们还可以通过减少锁的持有时间来提高Synchronized同步锁在自旋时获取锁资源的成功率,避免Synchronized同步锁升级为重量级锁。

3.2 Lock优化

  Lock同步锁(以下简称Lock锁)需要的是显示获取和释放锁,这就为获取和释放锁提供了更多的灵活性。
《Java性能调优实战》笔记(一)Java编程性能调优、多线程性能优化_第14张图片
  不管使用Synchronized同步锁还是Lock同步锁,只要存在锁竞争就会产生线程阻塞,从而导致线程之间的频繁切换,最终增加性能消耗。因此,如何降低锁竞争,就成为了优化锁的关键。
  在Synchronized同步锁中,我们了解了可以通过减小锁粒度、减少锁占用时间来降低锁的竞争。在Lock中,可以利用Lock锁的灵活性,通过锁分离的方式来降低锁竞争。比如,Lock锁实现了读写锁分离来优化读大于写的场景。

3.3 乐观锁优化

  乐观锁相比悲观锁来说,不会带来死锁、饥饿等活性故障问题,线程间的相互影响也远远比悲观锁要小。同时,乐观锁没有因竞争造成的系统开销,所以在性能上也是更好。
  CAS是实现乐观锁的核心算法。
  CAS是调用处理器底层指令来实现原子操作。处理器和物理内存之间的通信速度要远慢于处理器间的处理速度,所以处理器有自己的内部缓存。
  在执行操作时,频繁使用的内存数据会缓存在处理器的L1、L2 和L3高速缓存中,以加快频繁读取的速度。图示:
《Java性能调优实战》笔记(一)Java编程性能调优、多线程性能优化_第15张图片
  乐观锁在并发性能上要比悲观锁优越,但是在写大于读的操作场景下,CAS失败的可能性会增大,如果不放弃此次CAS操作,就需要循环做CAS重试,这无疑会长时间地占用CPU。
  在JDK1.8中,Java提供了一个新的原子类LongAdder。LongAdder在高并发场景下会比AtomicInteger和AtomicLong的性能更好,代价就是会消耗更多的内存空间。
  LongAdder的原理就是降低操作共享变量的并发数,也就是将对单一共享变量的操作压力分散到多个变量值上,将竞争的每个写线程的value值分散到一个数组中,不同线程会命中到数组的不同槽中,各个线程只对自己槽中的value值进行CAS操作,最后在读取值的时候会将原子操作的共享变量与各个分散在数组的value值相加,返回一个近似准确的数值。
  在日常开发中,使用乐观锁最常见的场景就是数据库的更新操作了。为了保证操作数据库的原子性,我们常常会为每一条数据定义一个版本号,并在更新前获取到它,到了更新数据库的时候,还要判断下已经获取的版本号是否被更新过,如果没有,则执行该操作。

3.4 上下文切换优化

  在并发程序中,并不是启动更多的线程就能让程序最大限度地并发执行。线程数量设置太小,会导致程序不能充分地利用系统资源;线程数量设置太大,又可能带来资源的过度竞争,导致上下文切换带来额外的系统开销。
  线程的生命周期:
《Java性能调优实战》笔记(一)Java编程性能调优、多线程性能优化_第16张图片
  上下文切换可以分为两种:一种是程序本身触发的切换,称为自发性上下文切换;另一种是由系统或者虚拟机诱发的非自发性上下文切换。
  在多线程编程中,执行调用以下方法或关键字,常常就会引发自发性上下文切换:

sleep()
wait()
yield()
join()
park()
synchronized
lock

  非自发性上下文切换指线程由于调度器的原因被迫切出。常见的有:线程被分配的时间片用完,虚拟机垃圾回收导致或者执行优先级的问题导致。
  在Linux系统下,可以使用Linux内核提供的vmstat命令,来监视Java程序运行过程中系统的上下文切换频率,图示:
《Java性能调优实战》笔记(一)Java编程性能调优、多线程性能优化_第17张图片
  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上下文切换:
《Java性能调优实战》笔记(一)Java编程性能调优、多线程性能优化_第18张图片
  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

  优化多线程上下文切换的几种方法:

  • 1、竞争锁优化
      在多线程编程中,锁其实不是性能开销的根源,竞争锁才是。锁的优化归根结底就是减少竞争。一些具体的方法:
  1. 减少锁的持有时间
  2. 降低锁的粒度
      可以考虑将锁粒度拆分得更小一些,以此避免所有线程对一个锁资源的竞争过于激烈。具体方式有以下两种:锁分离和锁分段。
      读写锁实现了锁分离,也就是说读写锁是由“读锁”和“写锁”两个锁实现的,其规则是可以共享读,但只有一个写。
      ConcurrentHashMap就使用了锁分段。
  3. 非阻塞乐观锁替代竞争锁
      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虚拟机的垃圾回收

3.5 并发容器的使用

  切忌在并发场景下使用HashMap。因为在JDK1.7之前,在并发场景下使用HashMap会出现死循环,从而导致CPU使用率居高不下,而扩容是导致死循环的主要原因。虽然Java在JDK1.8中修复了HashMap扩容导致的死循环问题,但在高并发场景下,依然会有数据丢失以及不准确的情况出现。
  要在并发环境下,选择Map实现类时,可以选择ConcurrentHashMap。
  虽然ConcurrentHashMap的整体性能要优于Hashtable,但在某些场景中,ConcurrentHashMap依然不能代替Hashtable。例如,在强一致的场景中ConcurrentHashMap就不适用,原因是ConcurrentHashMap中的 get、size 等方法没有用到锁,ConcurrentHashMap是弱一致性的,因此有可能会导致某次读无法马上获取到写入的数据。
  如果对数据有强一致要求,则需使用Hashtable;在大部分场景通常都是弱一致性的情况下,使用ConcurrentHashMap即可;如果数据量在千万级别,且存在大量增删改操作,则可以考虑使用ConcurrentSkipListMap。
《Java性能调优实战》笔记(一)Java编程性能调优、多线程性能优化_第19张图片

3.6 线程池大小的设置

  一般多线程执行的任务类型可以分为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中的主要应用。

你可能感兴趣的:(优化,性能优化,java,开发语言,多线程)