Post

Java性能优化指南(二)

在编写代码的时候,应该记住一些基本的最佳实践,从而规避潜在的性能问题。同样的任何的优化,都脱离不了具体的业务场景,各种优化原则也只有在正确的场景下才能发挥作用。

堆内存使用实践

分析堆内存

要了解系统的内存使用情况,首先要能够分析内存的使用情况,这里主要指的是JVM中的堆。

需要对堆内存进行分析的时候,一般都是出现内存溢出错误(OutOfMemoryError)的时候,可能的原因有如下几种

  1. JVM没有足够的原生内存可以使用 解决的办法一般是增加内存,或者减少内存需求
  2. 永久代或者Metaspace内存不足 这种情况一般是系统较为复杂,加载了太多的类。解决的办法就需要增大对应的内存大小;或者可能出现类加载器内存泄漏,一些类加载器无效之后,没有退出作用域,依然可以通过GC Roots访问到,那么这些类加载器加载的类对应的元数据也就无法释放,最终出现异常。这需要从程序上进行分析和排查问题。
  3. JVM堆的大小不足以容纳活跃对象 这是最为常见的情况,一种是活跃线程太多,程序在运行过程中,创建了大量的对象,这些对象的大小超出了堆的大小,这种通过改写程序代码,或者增大堆空间即可;另一种则可能是引用存在内存泄漏的情况,比如持续分配新对象,并且新的对象的一直保留在作用域也就是GC Roots可达,常见的比如使用类变量保存集合类,并持续写入对象,这种情况增大堆空间也于事无补,需要找到泄漏点并对其进行修改。
  4. JVM执行GC耗时太多 Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded这种情况是连续5次Full GC释放的内存都不足堆的2%导致的,一般比较少见。

一般情况下,我们都会配置系统在出现内存溢出的时候,进行堆转储,-XX:+HeapDumpOnOutOfMemoryError-XX:HeapDumpPath=<path>通过MAT等分析工具,我们可以知道堆中详细的内存分布情况,以及各个对象之间的关联,从而找到出现问题的根源。

使用MAT分析内存的时候,有三个术语:深对象 = 包括自身 + 引用对象的大小;保留对象,深对象 - 共享对象的大小;浅对象,保留对象 - 应用对象的大小,只包括自身和引用;如果一个对象被回收,回收的大小等于保留对象的大小。 有时候我们可以需要快速查看堆中对象的数量,那么使用jmap获取统计直方图则是最快速的手段,jmap -histo:live process_id

减少内存使用

要优化程序的性能包括之前提到的各种JVM的优化策略都很重要,但如果能减少堆内存的使用,那么无疑是最好的做法。

减少对象大小

  1. 只有在需要的时候定义变量,减少实例变量的个数可以显著降低内存消耗
  2. 使用占用空间更少的类型
  3. 减少无用的引用,及时是空值也会占用空间

    延迟初始化

    如果一些对象实例,并非一定会使用到,那么可以等到需要的时候再进行创建,从而减少无必要的内存浪费。不过要考虑在并发情况下的资源同步问题,可以采用双重检查锁来处理。 尽快清理对象,在程序运行过程中将一些变量设置为null,可以使所引用的对象尽快被GC回收,但是实际作用比较有限。

    使用不可变对象和标准化对象

    不可变对象意味着可以安全地在不同的上下文中共享,从而减少内存的使用。标准化对象时是指不可变对象的单一化表示,这其中的一个例子就是Boolean.TRUEBoolean.FLASE,这两个类在内存中只需要存储一份,却可以被安全第共享,此外例如String的intern()方法,也是使用该字符串的一个标准化版本。

    字符串保留

    字符串是Java中最常见的对象,因为字符串的不可变特性,如果有大量的字符串是相同的,那么这些字符串都可以共用相同的一个标准化版本而不用额外占用内存空间。 String类提供了自已的一个标准化方法:intern(),这是一个native方法,当一个字符串调用这个方法的时候

  4. JDK6中,intern()方法会把首次遇到的字符串实例复制到永久代(可以就当是方法区)中,返回的也是永久代中这个字符串实例的引用,因为JDK6中永久代大小的限制,因此intern()方法并不推荐使用,而是推荐使用自己基于Map来实现的标准化类。
  5. JDK7和JDK8中,Stirng会维护一个字符串池,如果池中存在字符串equals调用的字符串,那么返回池中的字符串。否则这个调用的字符串对象的引用,加入到池中,并返回对象的引用。也就是,如果存在堆中的对象,会直接保存对象的引用,而不会重新创建对象。这样可以保证字符串池中,具有相同内容的字符串只有一份。并且由于字符串常量池已经移动到了堆中,因此不容易出现JDK6中容易出现的OOM问题。

直接使用双引号声明出来的String对象会直接存储在常量池中。 如果不是用双引号声明的String对象,可以使用String提供的intern方法。intern 方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中

1
2
3
4
    String s = new String("1");
    s.intern();
    String s2 = "1";
    System.out.println(s == s2);

这段代码输出的结果为false, String s = new String("1")会创建两个对象,一个是位于字符串常量池中的对象假设为对象A,一个是在堆中的String对象,s表示的就是这个在堆中的对象为对象B,s.intern()会返回对象A,但是并不会改变s为对象B的事实,String s2="1"s2也是字符串产量池中的对象A。A和B的地址并不一致,因此输出false

1
2
3
4
    String s3 = new String("1") + new String("1");
    s3.intern();
    String s4 = "11";
    System.out.println(s3 == s4);
  • 这段代码在JDK6中,输出false,因为s3为堆内存,而s4位于永久代中,显然是两个不同的地址;
  • 而在JDK7和8中,输出为true,当调用s3.intern()之后,s3依然位于堆内存中,此时字符串常量池中并没有等于”11”的内容,但是由于常量池也位于堆中,此时常量池不再重新创建对象保存,而是直接保存了堆中对应对象的引用,也就是s3的引用,当String s4 = "11"的时候,s4返回常量池中的引用,这个引用和s3的地址就是完成一样的。因此输出true
  • 如果调换s3.intern();String s4 = "11";的位置,贼JDK7和8也会输出false,因为String s4 = "11";的时候常量池没有”11”因此会创建一个对象,并返回这个对象的引用给s4,而s3.intern()会返回s4的引用,但是这个地址和s3并没有关系。因此输出false

如果有大量重复的字符串,占据了很大一部分堆,那么使用这个方法就可以节约大量的内存。

保留字符串的常量池是一个大小固定的HashTable,在JDK7u40之前的版本,这个表默认只有1009个桶,在之后,这个值默认未60,013。因为这个HashTable是JVM内部实现的,无法自动拓展,只能在启动的时候设置。在桶的数量较少的情况,可能会出现大量的Hash冲突,导致搜索速度变慢。

JDK7开始,可以使用-XX:StringTableSize=N当需要保持大量的字符串,应该增加这个值。并且应该尽量使这个值为素数,以便在hash计算的时候,获得较高的效率。

对象重用

这个也是能够想到解决内存的一个简单直接的方法,但是重用对象,会导致这部分对象会被转移到老年代中,从而增加老年代GC和Full GC的时间,因为这些GC所花费的时间和老年代中仍然存活的对象数量成正比。重用对象的原因是因为很多对象初始化的成本很高,与增加的GC时间相比,重用更加有效。不过在重用对象的时候,依然需要考虑可能带来的负面影响。 常见需要进行重用的对象有

  1. 线程池:初始化成本较高
  2. JDBC连接池:初始化成本高
  3. 大型数组
  4. 原生NIO缓冲区:使用allocateDirect()方法分配原生内存,操作很昂贵,最好的做法是创建一个很大的缓冲区,然后通过切割的方式来管理
  5. 随机数生成器:Random和SecureRandom,生成它们的代价是很高的 要重用对象有两种主要的做法,对象池和线程局部变量。

  6. 对象池,对象池的使用有几个重点的考虑,何时创建,创建的数量多寡,何时使用和返回,这些都会影响到对象池的性能,下面是几个考虑的要素
    • GC影响,保持大量的对象会降低GC的效率,有时候会非常显著
    • 同步,对象池必然是共享的,如果频繁进行移除和替换,可能存在大量的竞争,这会极大影响性能
    • 限流,这个是一个正面的影响,对于稀缺资源,对象池可以起到限流的作用,保护系统不要超出处理能力,典型的比如数据库连接池、Tomcat等容器的连接池
  7. 线程局部变量,将对象保持在ThreadLocal中可以在线程内部共享,因为不需要处理多线程共享和同步的问题,使用线程局部会更加容易。但是由于内在特性,其无法具备限流的功能,一般在可重用对象和线程存在一对一关系的时候,线程局部变量是最佳的选择

非确定引用

一般来说,指向一个对象的普通引用实例变量就是一个强引用,比如MyObject object = new MyObject()object变量就是一个指向内存中一个MyObject对象(所引对象)的强引用。与强引用对应的就是非确定引用,典型的是SoftReference和WeakReference。非确定引用最大的优势在于,它们最终会被GC回收。下面简单说明一下它们的工作原理。

  1. 软引用 本质上是一个比较大的,最近最久未使用(LRU)对象池,如果一个对象在以后有很大的机会重用,又不希望永远驻留在内存中,可以使用软引用。 软引用被释放的条件为:所引对象不能有其他的强引用,软引用为指向所引对象的唯一引用,并且该软引用最近没有被访问过,则所引对象会在下一次GC周期释放
    1
    2
    3
    
    long ms = SoftRefLRUPolicyMSPerMB * AmountOfFreeMemoryInMB; 
    if (now - last_access_to_reference > ms) 
     free the reference
    

    -XX:SoftReflRUPolicyMSPerMB=N可以设置第一个值,默认未1000,第二个参数是在一个GC周期完成之后,堆中空闲内存的大小。如果内存充足,那么所引对象可以在内存中逗留比较长的时间,而不会立刻被回收。 如果对象的数目不是很多,使用软引用可以工作得很好,否则应该考虑使用固定大小的对象池来实现LRU缓存,一般可以选择Ehcache或者Guava Cache

  2. 弱引用 弱引用的对象在每个GC周期都可以回收,只有当所引对象会同时被几个线程使用的时候,才考虑使用弱引用。弱引用意味着如果强引用一旦解除,那么很快所引对象就会被回收。实际场景中,可以使用弱引用来对应用内存泄露进行检测,将可能存在泄漏的对象保存到Weakreference中,如果不存在其他强引用则其会很快被释放,否则则会长期存在。

对于非确定引用,所引对象至少有两个强引用指向它,一个是创建的时候的原始强引用,另一个是JVM创建的,在所引对象队列上的一个新的强引用。在所引对象被回收强,必须清理掉这些强引用,这通常是由引用队列代码来处理:如果队列上新对象创建,代码会得到通知,并立即移除指向该对象的所有强引用。之后在下一个GC周期中,非确定引用对象被释放,具体的细节依赖于所选择的非确定引用类型。

  1. 终结器和最终引用 如果一个类定义finalize()方法,则其会在被回收的时候,被调用。JVM使用一个私有的引用类finalizer来记录定义了finalize()方法的对象,当其被分配的时候,会有两个对象,一个是对象本身,一个是指向该对象的finalizer引用。这个引用需要再GC的时候调用对象的finalize()方法。因此finalizer类需要能够访问到所引对象。也因此和其他非确定引用有一个不同是,如果finalizer引用还在引用队列中,那么索引对象不会被回收,实际上此时还存在一个强引用(软引用和弱引用等调用get的时候会返回null),只有当finalizer对象从引用队列中移除之后,才可以释放索引对象,因此其对GC的性能影响,以及对内存的消耗更多。

    finalize()方法还存在一个问题,其只会被调用一次。如果在finalize第一次调用的时候,有重新创建一个指向所引对象的引用,那么当下一次所引对象可以被GC的时候,则该方法不会再被调用 使用jmap -finalizerinfo process_id可以查看finalizer引用队列的信息

  2. 虚引用 和弱引用类似,一旦没有指向所引对象的强引用,所引对象可以很快被清理,最大的区别在于虚引用的get方法总是返回null。虚引用必须和引用队列(ReferenceQueue)联合使用。

  3. 引用队列 当垃圾回收器准备回收一个对象时,如果发现它还有非确定引用,就会在回收对象的内存之前,把这个非确定引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了非确定引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个非确定引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。实际上,可以利用非确定引用的这个特性,自行实现类似fianlize的功能,并且可以获得更好的性能。

原生内存使用实践

JVM使用的内存,除了最大头的堆空间,还有一部分被称为非堆(no heap)的原生内存空间。一般我们最关心的原生内存空间是通过JNI或者NIO分配的原生内存空间。

提交内存和保留内存,当使用-Xms512m -Xmx2048m启动JVM之后,512m的初始内存为提交内存,而2048m为操作系统给JVM承诺的可使用内存也成为保留内存,考察性能的时候,需要以提交内存为准。

  • 线程栈是个例外,JVM每次创建线程的时候,操作系统会分配一些原生内存来保存线程栈,线程栈是在创建的时候分配的。
  • 原生NIO缓冲区 在NIO中零拷贝需要使用原生缓冲区的支持,减少在JVM所在的用户态和操作系统所在的内核态之间做数据复制。直接调用allocateDirect()方法可以分配原生内存,但是其代价很昂贵,应该尽量使用对象池技术重用这部分内存。可以通过-XX:MaxDirectMemorySize=N来限制原生内存的大小,默认为0
  • JDK8中可以使用原生内存跟踪(Native Memory Tracking,NMT)来跟踪原生内存使用情况

针对操作系统优化JVM

大页

页是操作系统管理物理内存的一个单元,也是分配内存的最小单元。例如:要分配一个字节,操作系统会分配一个整页,程序中后续的内存分配都会从这个页中获取,直到分配完毕,操作系统会再分配一个新的页。

操作系统分配的页数一般要比物理内存能容纳的页数多很多,这就是存在分页机制的原因:地址空间中的页会被移入或移出交换空间(或其他存储,跟页中包含的内容有关)。这意味着,这些页和它们在计算机物理内存中所占的位置间存在某种映射。这些映射有两种不同的处理方式。所有的页映射都保存在一个全局页表中(操作系统可以扫描这个表,找到特定的映射),最常用的映射保存在 TLB(Translation Lookaside Buffers)中。TLB 保存在一个快速的缓存中,所以通过 TLB 表项访问页要比通过页表访问快得多。机器中 TLB 表项的数目有限,TLB 会用作 LRU(Least Recently Used,最近最少使用的)缓存,因此最大化 TLB 表项的命中率就变得非常重要。因为每个表项表示一个内存页,所以增大应用所使用的页的大小一般会有所帮助。如果每个页能表示更多内存,则用更少的TLB 表项就能涵盖整个程序的内存,这样当需要某个页时,在 TLB 中找到的可能性更大。

JDK支持-XX:UseLargePages选项,默认识关闭的,启动这个选项意味着操作系统需要为用户单独锁定内存页。在windows中需要server版本才能支持。因为我们使用的服务器大多数为Linux,主要说明Linux大页的使用。

  1. 确定内核支持哪些大页大小,最常见的是2MB
    1
    2
    
    grep Hugepagesize /proc/meminfo
    Hugepagesize:       2048 kB
    
  2. 计算需要使用多少大页,如果要分配4GB的堆,系统支持2MB的大页,则需要2048个,一起其他原生内存的部分多预估10%为2200,同时还需要考虑其他会使用大页的程序。
  3. 将所需要的大页值写入到操作系统,临时生效
    1
    
     echo 2200 > /proc/sys/vm/nr_hugepages
    
  4. 保存到/etc/sysctl.conf写入到系统启动参数
    1
    
    sys.nr_hugepages=2200
    
  5. 一个用户可以分配的内存页数量有限,通过编辑/etc/security/limits.conf,来添加memlock条目
    1
    2
    
     appuser soft    memlock    4613734400
     appuser hard    memlock    4613734400
    
  6. java -Xms1G -Xmx1G -XX:+UseLargePages -version验证
  7. 透明大页,在kernel 2.6.32之后,Linux支持透明大页,通过以下方法设置
    1
    2
    3
    4
    5
    
    # cat /sys/kernel/mm/transparent_hugepage/enabled 
    always [madvise] never 
    # echo always > /sys/kernel/mm/transparent_hugepage/enabled 
    # cat /sys/kernel/mm/transparent_hugepage/enabled 
    [always] madvise never
    

    这会使整个系统中的所有程序都使用大页,如果启用了透明大页,就不用使用UseLargePages标志了,否则会使用传统的大页。

压缩的OOP

同一个应用,在32位JVM中的性能表现一般会比64位JVM的表现要好一点,主要的原因在于,32位的对象引用占4字节,而64位的对象引用占8字节,是前者的2倍,这就需要更多GC周期,因为堆中留给其他数据的空间更少。 JVM可以使用压缩的oop来弥补额外的内存消耗,oop表示普通对象指针(ordinary object pointer)。在oop只有32位,只能使用2的32方的内存也就是4GB,如果oop是64位的时候,则可以存储TB级别的内存。

为了减少对象引用的空间消耗,又可以使用更大的内存空间。JVM选择一个折中的方案,设计35位的oop,这样的指针可以引用32GB 的内存,在堆中占的空间也比64位的引用少。问题是没有35位长的寄存器可以存放这样的引用。不过 JVM 可以假设引用的最后3位都是0。现在,就不是所有的引用都能保存在堆中了。当应用被存入64位的寄存器时,JVM 可以将其左移3位(末尾添加 3 个 0)。而当从寄存器读出时,JVM 又可以右移3位,丢弃末尾的0。这样JVM就有了可以引用32GB内存的指针,而且每个指针在堆中只占用32位。

然而这也意味着,对于不能被8整除的地址上的任何一个对象,JVM都无法访问,因为从压缩的oop得到的任何地址均以3个0结尾。第一个可能的oop是0x1,移位之后是0x8。下一个oop0x2,在移位后变成了0x10(16),所以对象必须位于 8 字节的边界上。

实际上是将3个低位作为一个操作单元,压缩后的32位指针地址,表示高位的32位,低位的3位具体值被忽略

其实在 JVM 中(不管是 32 位的还是 64 位的),对象已经按 8 字节边界对齐了,也就是对象的地址都对齐为最后3位为0的格式;对于大部分处理器,这种对齐方案都是最优的。所以使用压缩的 oop 并不会损失什么。如果 JVM中的第一个对象保存到位置 0,占用 57 字节,那下一个对象就要保存到位置 64,浪费了 7字节,无法再分配。这种内存取舍是值得的(而且不管是否使用压缩的 oop,都是这样),因为在 8 字节对齐的位置,对象可以更快地访问。

不过这也是为什么 JVM 没有尝试模仿 36 位引用(可以访问 64 GB 的内存)的原因。在那种情况下,对象就要在 16 字节的边界上对齐,在堆中保存压缩指针所节约的成本,就被为对齐对象而浪费的内存抵消了。

压缩的oop使用-XX:UseCompressedOops开启,在JDK7之后,只要堆小于32G,会默认开启。开启之后在64位JVM上,对象引用的大小也为4个字节。

线程和线程同步

线程池

在编写多线程代码的时候,一般会选择使用线程池来实现,在Java中即为ThreadPoolExecutor,线程池工作的原理大致如下:有一个队列,任务被提交到这个队列中;一定数量的线程从该队列中获取任务,然后执行,如果所有的线程都在忙碌,则创建新的线程执行,直到达到最大线程数;执行完成任务后,线程会返回任务队列中,继续检索和执行下一个任务。

线程池的功能和之前的对象池有类似之处,降低频繁创建线程带来的性能损耗使用线程复用取而代之,起到必要的限流作用,防止同时执行太多的线程拖垮系统,这点在很多服务器中非常有用。

最大值和最小值

因此线程池中的初始数量和最大数量两个参数是最重要的调优参数,一般而言设置最大线程数的时候需要考虑线程的计算类型,如果是高度CPU密集型的计算,则设置为小于可用CPU的核心数量是一个号的选择,因为你还需要为其他的引用预留足够的计算资源。而如果CPU资源在运行过程中比较充裕,可以适当增加线程数量,但是不建议增加超过CPU可用数量的8倍以上。而初始数量,则更多的是考虑系统是否一开始就需要处理大量的连接请求。对这两个参数进行调优,最好的做法还是进行完备的测试,根据测试过程中的各项指标进行调优。

这里隐藏着一个重要的优化原则,如果还向瓶颈处增加负载,性能会显著下降。相反,如果减少了当前瓶颈处的负载,性能可能会上升

线程池任务数量

设置数量主要出于响应时间考虑和限流的功能考虑,如果线程池任务队列非常长,那么最后提交的任务可能会需要非常久之后才能得到执行时间。而过长的任务队列会对系统产生额外的压力

ThreadPoolExecutor有三种任务类型,会英系那个何时启动一个新线程

  1. SynchronousQueue 如果任务到来,但是创建的线程已经达到最大值,并且所有线程都在忙碌,则新任务会被拒绝RejectedExecutionExceptionThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(1,100,50,TimeUnit.SECONDS, new SynchronousQueue());
  2. 无界队列 不会拒绝任何任务,最多仅会按最小线程数创建线程,最大线程数参数被忽略。 ` ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(1,4,50,TimeUnit.SECONDS, new LinkedBlockingQueue<>());`
  3. 有界队列 如果队列已满,而又有新任务加进来,此时才会启动一个新线程。这里不会因为队列已满而拒绝任务,相反,新启动的线程会运行队列中的第一个新任务,为新来的任务腾出空间。

ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(1,4,50,TimeUnit.SECONDS, new ArrayBlockingQueue<>(5));

如果所有的线程都在忙碌,并且有界队列已满,则会拒绝新任务的添加RejectedExecutionException。上面这段代码最多可添加9个任务即正在执行4个、队列中5个。

这个算法背后的逻辑是,该线程池大部分时间都使用最小线程,几十有适量的任务在队列中等待,这时线程池就可以起到节流阀的作用,如果积压的请求非常多了,队列已满。那么尝试着运行更多的线程来清理,这时候最大线程数起到节流的作用。

ForkJoinPool

内部使用无界任务队列,ForkJoinPool是为了配合分治算法的使用而设计的:任务可以递归地分解为子集,子集并行处理,并将结果合并到一个结果中。典型的就是快速排序。其特点是会创建大量的任务,所有的任务都要等待它们派生出的任务先完成,然后才能完成,依次类推,所有的任务依次合并,知道所有的任务都合并得到结果。ForkJoinPool允许其中的线程创建新任务,然后挂起当前的任务,当任务被挂起后,线程可以执行其他等待的任务,这种特性可以使用少量线程来管理和运行大量的任务。

ForkJoinPool是一个易于使用的分治算法编程模型,但是过程中会创建大量的任务,这会对GC性能产生影响。

此外,ForkJoinPool还实现了work-stealing机制,池中的每一个线程都会创建自己的任务队列,线程优先处理自己队列中的任务,如果这个队列已空,则会从其他线程的队列中窃取任务。这对不均衡的任务非常有利,可以使整个计算过程中线程都处于忙碌的状态。

ForkJoinPool默认的线程数量等于机器上的CPU数量,如果同时运行多个JVM,那么应该限制线程数量。

线程同步

同步(Synchronization) ,既是一个线程访问数据未结束的时候,其他线程不得对同一个数据进行访问。对数据的访问看上去是串行的,每次只有一个线程能访问内存。 实现同步的最常见方法就是使用锁(Lock),这是一种强制机制,每一个线程访问数据或者资源前,首先试图获取(acquire) 锁,并在访问结束之后释放(release)锁,试图获取被占用的锁,线程会等待,直到锁重新可用。另一种是使用CAS的自旋机制。

同步的代价

  1. 同步和可伸缩性 同步带来最大的代价,就是程序并行度的降低,如果应用被分割到多个线程运行,增加线程可以带来的加速比(Speedup)为

speedup

P为并行运行部分所花费的时间。可知串行运行的代码越多,那么整体加速比越低。多线程带来的性能改善也就越少。

  1. 锁定对象的开销 获取同步锁如果锁没有争用,那开销很小。CAS效率略高于使用synchronized关键字。如果锁存在争用,那么同步锁会从非膨胀锁编程膨胀锁,每个线程的获取同步锁的事件时固定的,CAS的访问时间则无法确定。 另一个开销依赖于Java内存模型(Java Memory Model),Java通过JMM对听不相关的内存语义提供保证,使其适用于基于CAS的保护、传统同步锁和volatile关键字。

同步是为了保护对内存中变量的访问,内存变量可能会存储在寄存器中,这相比内存要高效,但是寄存器是属于具体CPU核心的,也就是同时运行在不同核心上的线程之间的寄存器值是不共享的,线程之间寄存器的读取和刷新机制就需要线程同步来进行控制。一般,当一个线程离开某个同步块时,必须将任何修改过的值刷新到主内存中。这意味着进入该同步块的其他线程将能看到最新修改的值。类似地,基于 CAS 的保护确保操作期间修改的变量被刷新到主内存中。标记为 volatile 的变量,无论什么时候被修改了,总会在主内存中更新。

当一个域声明为volatile类型后,编译器与运行时会监视这个变量:它是共享的,而且对它的操作不会与其他的内存操作一起被重排序。volatile变量不会缓存到寄存器或者缓存在对其他处理器隐藏的地方,而是每次写入都会同步到主内存中。所以读一个volatile类型的变量时,总会返回由某一线程所写入的最新值,也就是从主内存中读取。volatile只能保证可见性,并不足以保证类型 ++ 操作的原子性。

避免同步

  1. 使用线程局部变量ThreadLocal,将对象共享的粒度细化到线程中,可以避免线程之间相互竞争。
  2. 基于CAS的替代方案,和传统的同步方案相比存在下面的特点
  • 如果访问的是不存在竞争的资源,那么基于CAS的保护要比传统同步的速度稍微快一点
  • 如果访问的资源存在轻度或适度的竞争,那么基于CAS的保护要快于传统的同步
  • 虽则所访问资源竞争越来越激烈,在某一时刻,传统的同步就会称为更搞笑的选择,一般这只会出现在运行大量线程的大型机器上
  • 当被保护的值有多个读取,但不被写入的时候,基于CAS的保护不会受竞争的影响

CAS本质上是一种自旋的乐观锁,通过不断地重试,检测可访问条件,中间不会出现资源的锁和占用。但是可能出现ABA的问题,也就是中间状态丢失,一般可以通过递增的版本号来对这类问题进行处理

伪共享

伪共享和CPU处理器的高速缓存有关系,如果程序访问对象中的某个特定实例变量,则很有可能会访问邻接的变量实例。这些实例变量会被加载到当前核的高速缓冲中,这样内存访问会非常快。但是这种模式也存在问题,如果变量被volatile修饰,那么当线程修改所属本地缓存中的值的时候,必须要同时通知其他的核心,使其从内存中重新加载。

严格说,伪共享未必会涉及到同步或者volatile变量,只要CPU缓存中有任何数据被写入,其他保持了同样范围的缓存都会被作废,在Java中,只有在同步原语(包括CAS和volatile构造)结束后必须吸入内存。

为了阻止伪共享,理想的情况是减少所涉及变量的写入频率,先在本地变量中修改,再一次性同步到volatile变量中。 第二种做法是通过填充(padding)相关变量,以免其被加载到相同的缓存行中。不过由于乱序重排的存在,可能效果会不尽如人意,JDK8中引入 Contended注解可以自动生成。不过应用较少

线程调优

  1. 调整大小 除了使用线程池,通过复用减少线程的创建外,考虑到硬件资源有限,而每一个线程都会占用一定的原生内存空间,64位系统默认为1MB。这个数值略大,一般可以通过降低栈的大小,来减少这部分内存的损耗,比如使用-Xss=256k

  2. 偏向锁 如果一个线程最近用到了某一个锁,那么线程下一次执行同一把锁所保护的代码,锁需要的数据可能仍然保存在处理器的缓存中。为了增加缓存的命中率,可以给这个线程优先获得这把锁的权利。这也会增加其他的线程的等待时间,偏向锁默认开启,通过-XX:-UseBiaseLocking可以关闭。

  3. 减少同步块的大小 从加速比公式可知,串行执行的代码越少,对于线程并发越有利,尽可能地减少同步块的大小,无论是使用传统的同步块方式,还是使用CAS的自旋方式,都会对性能产生正面的影响

分析线程

JMCJConsoleJvisualVM等工具都可以查看到JVM的线程栈信息。实际工作中,更多的是使用jstack等工具,获取JVM的线程栈快照,但是由于JVM只能在特定的位置(安全点 safepoint)转储出线程的栈,并且每次只能针对一个线程转储出栈信息,因此可能看到彼此冲突的信息,比如两个线程持有同一个锁,或者线程等待的锁,没有被其他线程持有等。但是通过jstack仍然可以一定程度上分析,应用被堵塞在什么地方,主要的资源争抢点在什么地方。同时,结合top -Hp process_id,可以找到特定的线程对资源的占用情况。

This post is licensed under CC BY 4.0 by the author.