多线程性能调优

原文:https://time.geekbang.org/column/article/101244#previewimg

Synchronized

Lock 同步锁是基于 Java 实现的。
Synchronized 是基于底层操作系统的Mutex Lock 实现的,每次获取和释放锁操作都会带来用户态和内核态的切换,从而增加系统性能开销。

Synchronized 在修饰同步代码块时,是由 monitorenter 和monitorexit 指令来实现同步的。进入 monitorenter 指令后,线程将持有 Monitor 对象,退出 monitorenter 指令后,线程将释放该 Monitor 对象。

JVM 使用了 ACC_SYNCHRONIZED访问标志来区分一个方法是否是同步方法。
当方法调用时,调用指令将会检查该方法是否被设置ACC_SYNCHRONIZED 访问标志。如果设置了该标志,执行线程将先持有 Monitor 对象,然后再执行方法。在该方法运行期间,其它线程将无法获取到该 Mointor 对象,当方法执行完成后,再释放该 Monitor 对象。

JVM 中的同步是基于进入和退出管程(Monitor)对象实现的。每个对象实例都会有一个 Monitor,Monitorr 可以和对象一起创建、销毁。

当多个线程同时访问一段同步代码时,多个线程会先被存放在 EntryList 集合中,处于 block 状态的线程,都会被加入到该列表。接下来当线程获取到对象的 Monitor 时,Monitor 是依靠底层操作系统的 Mutex Lock 来实现互斥的,线程申请 Mutex 成功,则持有该 Mutex,其它线程将无法获取到该 Mutex。

如果线程调用 wait() 方法,就会释放当前持有的 Mutex,并且该线程会进入WaitSet 集合中,等待下一次被唤醒。如果当前线程顺利执行完方法,也将释放 Mutex。

多线程性能调优_第1张图片
锁升级优化

JDK1.6新增了 Java 对象头实现了锁升级功能。

对象实例在堆内存中被分为了三个部分:对象头、实例数据和对齐填充。其中 Java 对象头由 Mark Word、指向类的指针以及数组长度三部分组成。

Mark Word
多线程性能调优_第2张图片
Synchronized 同步锁就是从偏向锁开始的,随着竞争越来越激烈,偏向锁升级到轻量级锁,最终升级到重量级锁。

1. 偏向锁
偏向锁主要用来优化同一线程多次申请同一个锁的竞争。
当一个线程再次访问这个同步代码或方法时,该线程只需去对象头的 Mark Word中去判断一下是否有偏向锁指向它的 ID,
无需再进入 Monitor 去竞争对象了。
当对象被当做同步锁并有一个线程抢到了锁时,锁标志位还是 01,“是否偏向锁”标志位设置为 1,并且记录抢到锁的线程 ID,表示进入偏向锁状态。

下图中红线流程部分为偏向锁获取和撤销流程:
多线程性能调优_第3张图片
在高并发场景下,当大量线程同时竞争同一个锁资源时,偏向锁就会被撤销,发生 stop the word 后,开启偏向锁无疑会带来更大的性能开销,我们可以通过添加 JVM 参数关闭偏向锁来调优系统性能。

-XX:-UseBiasedLocking // 关闭偏向锁(默认打开)

-XX:+UseHeavyMonitors  // 设置重量级锁

2. 轻量级锁
当有另外一个线程竞争获取这个锁时,由于该锁已经是偏向锁,当发现对象头 Mark Word 中的线程 ID 不是自己的线程 ID,就会进行 CAS 操作获取锁,如果获取成功,直接替换 Mark Word 中的线程 ID 为自己的 ID,该锁会保持偏向锁状态;如果获取锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。

下图中红线流程部分为升级轻量级锁及操作流程:
多线程性能调优_第4张图片
3. 自旋锁与重量级锁
轻量级锁 CAS 抢锁失败,线程将会被挂起进入阻塞状态。如果正在持有锁的线程在很短的时间内释放资源,那么进入阻塞状态的线程无疑又要申请锁资源。

JVM 提供了一种自旋锁,可以通过自旋方式不断尝试获取锁,从而避免线程被挂起阻塞。这是基于大多数情况下,线程持有锁的时间都不会太长,毕竟线程被挂起阻塞可能会得不偿失。

自旋锁重试之后如果抢锁依然失败,同步锁就会升级至重量级锁,锁标志位改为 10。在这个状态下,未抢到锁的线程都会进入 Monitor,之后会被阻塞在 _WaitSet 队列中。

我们还可以通过减少锁的持有时间来提高 Synchronized 同步锁在自旋时获取锁资源的成功率,避免 Synchronized 同步锁升级为重量级锁。

多线程性能调优_第5张图片
**在锁竞争不激烈且锁占用时间非常短的场景下,自旋锁可以提高系统性能。**一旦锁竞争激烈或锁占用的时间过长,自旋锁将会导致大量的线程一直处于 CAS 重试状态,占用 CPU 资源,反而会增加系统性能开销。所以自旋锁和重量级锁的使用都要结合实际场景。

关闭自旋锁:

-XX:-UseSpinning // 参数关闭自旋锁优化 (默认打开) 
-XX:PreBlockSpin // 参数修改默认的自旋次数。JDK1.7 后,去掉此参数,由 jvm 控制

动态编译实现锁消除 / 锁粗化

除了锁升级优化,Java 还使用了编译器对锁进行优化。JIT 编译器在动态编译同步块的时候,借助了一种被称为逃逸分析的技术,来判断同步块使用的锁对象是否只能够被一个线程访问,而没有被发布到其它线程。

确认是的话,那么 JIT 编译器在编译这个同步块的时候不会生成 synchronized 所表示的锁的申请与释放的机器码,即消除了锁的使用。

锁粗化同理,就是在 JIT 编译器动态编译时,如果发现几个相邻的同步块使用的是同一个锁实例,那么 JIT 编译器将会把这几个同步块合并为一个大的同步块,从而避免一个线程“反复申请、释放同一个锁“所带来的性能开销。

减小锁粒度
我们的锁对象是一个数组或队列时,集中竞争一个对象的话会非常激烈,锁也会升级为重量级锁。我们可以考虑将一个数组和队列对象拆成多个小对象,来降低锁竞争,提升并行度。

Lock 同步锁

悲观锁

多线程性能调优_第6张图片
高并发和高负载下:Synchronized由于竞争激烈会升级到重量级锁,性能没Lock锁稳定。

多线程性能调优_第7张图片
1. 读写锁 ReentrantReadWriteLock
读多写少
容易使写入线程遭遇饥饿状态,写入线程会因无法竞争到锁而一直处于等待状态。
ReentrantLock 是一个独占锁,同一时间只允许一个线程访问,而 RRW 允许多个读线程同时访问,但不允许写线程和读线程、写线程和写线程同时访问。读写锁内部维护了两个锁,一个是用于读操作的 ReadLock,一个是用于写操作的 WriteLock。
如何实现锁分离来保证共享资源的原子性?
RRW 也是基于 AQS 实现的,它的自定义同步器(继承 AQS)需要在同步状态 state 上维护多个读线程和一个写线程的状态,该状态的设计成为实现读读写锁的关键。RRW 很好地使用了高低位,来实现一个整型控制两种状态的功能,读写锁将变量切分成了两个部分,高 16 位表示读,低 16 位表示写。
一个线程尝试获取写锁时,会先判断同步状态 state 是否为 0。如果 state 等于 0,说明暂时没有其它线程获取锁;如果 state 不等于 0,则说明有其它线程获取了锁。
此时再判断同步状态 state 的低 16 位(w)是否为 0,如果 w 为 0,则说明其它线程获取了读锁,此时进入 CLH 队列进行阻塞等待;如果 w 不为 0,则说明其它线程获取了写锁,此时要判断获取了写锁的是不是当前线程,若不是就进入 CLH 队列进行阻塞等待;若是,就应该判断当前线程获取写锁是否超过了最大次数,若超过,抛异常,反之更新同步状态。

多线程性能调优_第8张图片

一个线程尝试获取读锁时,同样会先判断同步状态 state 是否为 0。如果 state 等于 0,说明暂时没有其它线程获取锁,此时判断是否需要阻塞,如果需要阻塞,,则进入 CLH 队列进行阻塞等待;如果不需要阻塞,则 CAS 更新同步状态为读状态。
如果 state 不等于 0,会判断同步状态低 16 位,如果存在写锁,则获取读锁失败,进入 CLH 阻塞队列;反之,判断当前线程是否应该被阻塞,如果不应该阻塞则尝试 CAS 同步状态,获取成功更新同步锁为读状态。

多线程性能调优_第9张图片
StampedLock
解决写入锁饥饿问题
StampedLock 控制锁有三种模式: 写、悲观读以及乐观锁,并且 StampedLock 在获取锁时会返回一个票据 stamp,获取的 stamp 除了在释放锁时需要校验,在乐观读模式下,stamp 还会作为读取共享资源后的二次校验。
一个写线程获取写锁的过程中,首先是通过 WriteLock 获取一个票据 stamp,WriteLock 是一个独占锁,同时只有一个线程可以获取该锁,当一个线程获取该锁后,其它请求的线程必须等待,当没有线程持有读锁或者写锁的时候才可以获取到该锁。请求该锁成功后会返回一个 stamp 票据变量,用来表示该锁的版本,需要 unlockWrite 并传递参数 stamp。
一个读线程获取锁的过程,首先线程会通过乐观锁 tryOptimisticRead操作获取票据 stamp ,如果当前没有线程持有写锁,则返回一个非 0 的 stamp 版本信息。线程获取该 stamp 后,将会拷贝一份共享
资源到方法栈,在这之前具体的操作都是基于方法栈的拷贝数据。之后方法还需要调用 validate,验证之前调用 tryOptimisticRead 返回的stamp 在当前是否有其它线程持有了写锁,如果是,那么 validate 会返回 0,升级为悲观锁;
否则就可以使用该 stamp 版本的锁对数据进行操作。
相比于 RRW,StampedLock 获取读锁只是使用与或操作进行检验,不涉及CAS 操作,即使第一次乐观锁获取失败,也会马上升级至悲观锁,这样就可以避免一直进行 CAS 操作带来的 CPU 占用性能的问题,因此 StampedLock 的效率更高。

乐观锁

CAS 是实现乐观锁的核心算法,它包含了 3 个参数:V(需要更新的变量)、E(预期值)和 N(最新值)。

1.CAS 如何实现原子操作

 // 基于 CAS 操作更新值
    public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }
    // 基于 CAS 操作增 1
    public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }
    
    // 基于 CAS 操作减 1
    public final int getAndDecrement() {
        return unsafe.getAndAddInt(this, valueOffset, -1);

2. 处理器如何实现原子操作

多线程性能调优_第10张图片
通常单核处理器能自我保证基本的内存操作是原子性的,当一个线程读取一个字节时,所有进程和线程看到的字节都是同一个缓存里的字节,其它线程不能访问这个字节的内存地址。
但多核处理器下,每个处理器维护了一块字节的内存,每个内核维护了一块字节的缓存,这时候多线程并发就会存在缓存不一致的问题,从而导致数据不一致。
处理器提供了总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。

当处理器要操作一个共享变量的时候,其在总线上会发出一个 Lock 信号,这时其它处理器就不能操作共享变量了,该处理器会独享此共享内存中的变量。但总线锁定在阻塞其它处理器获取该共享变量的操作请求时,也可能会导致大量阻塞,从而增加系统的性能开销。
于是,后来的处理器都提供了缓存锁定机制,也就说当某个处理器对缓存中的共享变量进行了操作,就会通知其它处理器放弃存储该共享资源或者重新读取该共享资源。目前最新的处理器都支持缓存锁定机制。

优化 CAS 乐观锁

for 循环不断重试 CAS 操作,如果长时间不成功,就会给 CPU 带来非常大的执行开销。

   public final int getAndSet(int newValue) {
        for (;;) {
            int current = get();
            if (compareAndSet(current, newValue))
                return current;
        }
    }

LongAdder
在高并发场景下会比 AtomicInteger 和 AtomicLong 的性能更好,代价就是会消耗更多的内存空间。
对实时性要求较低,返回的是一个近似的数值

原理就是降低操作共享变量的并发数,对单一共享变量的操作压力分散到多个变量值上,将竞争的每个写线程的 value 值分散到一个数组中,不同线程会命中到数组的不同槽中,各个线程只对自己槽中的 value 值进行 CAS 操作,,最后在读取值的时候会将原子操作的共享变量与各个分散在数组的 value 值相加,返回一个近似准确的数值。

ongAdder 内部由一个 base 变量和一个 cell[] 数组组成。当只有一个写线程,没有竞争的情况下,LongAdder会直接使用 base 变量作为原子操作变量,通过 CAS 操作修改变量;当有多个写线程竞争的情况下,除了占用 base 变量的一个写线程之外,其它各个线程会将修改的变量写入到自己的槽cell[] 数组中,最终结果可通过以下公式计算得出:

多线程性能调优_第11张图片

上下文切换

一个线程被暂停剥夺使用权,另外一个线程被选中开始或者继续运行的过程就叫做上下文切换(Context Switch)。
一个线程被剥夺处理器的使用权而被暂停运行,就是“切出”;一个线程被选中占用处理器开始或者继续运行,就是“切入”。
操作系统需要保存和恢复相应的进度信息,这个进度信息就是“上下文”了。

上下文:
它包括了寄存器的存储内容以及程序计数器存储的指令内容。
CPU 寄存器负责存储已经、正在和将要执行的任务,程序计数器负责存储 CPU 正在执行的指令位置以及即将执行的下一条指令的位置。
在多核CPU下,线程的切换消耗的性能更多,操作系统将CPU轮流分配给线程任务,且存在跨CPU的上下文切换。

线程的生命周期:

多线程性能调优_第12张图片

自发性上下文切换指线程由 Java 程序调用导致切出,在多线程编程中,执行调用以下方法或关键字,常常就会引发自发性上下文切换。

sleep()    //不释放锁
wait()  	  //释放锁
yield()	  //让步,让掉自己的CPU执行时间
join()		//t.join()方法阻塞调用此方法的线程(calling thread),直到线程t完成
park()
synchronized()
lock()

非自发性上下文切换指线程由于调度器的原因被迫切出。常见的有:线程被分配的时间片用完,虚拟机垃圾回收导致(stop the world)或者执行优先级的问题导致。

在 Linux 系统下,可以使用 Linux 内核提供的 vmstat 命令,来监视 Java 程序运行过程中系统的上下文切换频率,cs 如下图所示:
多线程性能调优_第13张图片
如果是监视某个应用的上下文切换,就可以使用 pidstat命令监控指定进程的 Context Switch 上下文切换…
多线程性能调优_第14张图片
在 Windows 下,我们可以使用 Process Explorer,来查看程序执行时,线程间上下文切换的次数。

上下文切换系统开销环节:
1.操作系统保存和恢复上下文;
2.调度器进行线程调度;
3.处理器高速缓存重新加载;
4.上下文切换也可能导致整个高速缓存区被冲刷,从而带来时间开销。

多线程上下文切换优化

竞争锁优化

多线程对锁资源的竞争会引起上下文切换,还有锁竞争导致的线程阻塞越多,上下文切换就越频繁,系统的性能开销也就越大。由此可见,在多线程编程中,锁其实不是性能开销的根源,竞争锁才是。

1.减少锁的持有时间
优化同步代码块的大小
2.降低锁的粒度
读写锁分离,锁分段(ConcurentHashMap)
3. 非阻塞乐观锁替代竞争锁
CAS 是一个无锁算法实现,保障了对一个共享变量读写操作的一致性。
CAS 算法将不会导致上下文切换。Java 的 Atomic 包就使用了 CAS 算法来更新数据,就不需要额外加锁。

原文: https://time.geekbang.org/column/article/102974

生产者消费者模型:

public class WaitNotifyTest {
    public static void main(String[] args) {
        Vector pool=new Vector();
        Producer producer=new Producer(pool, 10);
        Consumer consumer=new Consumer(pool);
        new Thread(producer).start();
        new Thread(consumer).start();
    }
}
	/**
	 * 生产者
	 * @author admin
	 *
	 */
	class Producer implements Runnable{
	    private Vector pool;
	    private Integer size;
	    
	    public Producer(Vector  pool, Integer size) {
	        this.pool = pool;
	        this.size = size;
	    }
	    
	    public void run() {
	        for(;;){
	            try {
	                System.out.println(" 生产一个商品 ");
	                produce(1);
	            } catch (InterruptedException e) {
	                // TODO Auto-generated catch block
	                e.printStackTrace();
	            }
	        }
	    }
	    private void produce(int i) throws InterruptedException{
	        while(pool.size()==size){
	            synchronized (pool) {
	                System.out.println(" 生产者等待消费者消费商品, 当前商品数量为 "+pool.size());
	                pool.wait();// 等待消费者消费
	            }
	        }
	        synchronized (pool) {
	            pool.add(i);
	            pool.notifyAll();// 生产成功,通知消费者消费
	        }
	    }
	}


	/**
	 * 消费者
	 * @author admin
	 *
	 */
	class Consumer implements Runnable{
	    private Vector  pool;
	    public Consumer(Vector  pool) {
	        this.pool = pool;
	    }
	    
	    public void run() {
	        for(;;){
	            try {
	                System.out.println(" 消费一个商品 ");
	                consume();
	            } catch (InterruptedException e) {
	                // TODO Auto-generated catch block
	                e.printStackTrace();
	            }
	        }
	    }
	    
	    private void consume() throws InterruptedException{
	        while(pool.isEmpty()){
	            synchronized (pool) {
	                System.out.println(" 消费者等待生产者生产商品, 当前商品数量为 "+pool.size());
	                pool.wait();// 等待生产者生产商品
	            }
	        }
	        synchronized (pool) {
	            pool.remove(0);
	            pool.notifyAll();// 通知生产者生产商品
	            
	        }
	    }

}

使用协程实现非阻塞等待
协程是一种比线程更加轻量级的东西,相比于由操作系统内核来管理的进程和线程,协程则完全由程序本身所控制,也就是在用户态执行。协程避免了像线切换那样产生的上下文切换,在性能方面得到了很大的提升。

减少 Java 虚拟机的垃圾回收
很多 JVM 垃圾回收器(serial 收集器、ParNew收集器)在回收旧对象时,会产生内存碎片,从而需要进行内存整理,在这个过程中就需要移动存活的对象。而移动内存对象就意味着这些对象所在的内存地址会发生变化,因此在移动对象前需要暂停线程,在移动完成后需要再次唤醒该线程。因此减少 JVM 垃圾回收的频率可以有效地减少上下文切换。

并发容器的使用:识别不同场景下最优容器

ConcurrentHashMap
弱一致性
JDK1.8,ConcurrentHashMap 做了大量的改动,摒弃了 Segment 的概念。由于 Synchronized 锁在 Java6 之后的性能
已经得到了很大的提升,所以在 JDK1.8 中,Java 重新启用了 Synchronized 同步锁,通过Synchronized 实现 HashEntry 作为锁粒度。这种改动将数据结构变得更加简单了,操作也更加清晰流畅。

在高并发,且数据量大的情况下,ConcurrentHashMap中链表会进化成红黑树,而红黑树在并发情况下,删除和插入过程中有个平衡的过程,会牵涉到大量节点,因此竞争锁资源的代价相对比较高。

在非线程安全的 Map 容器中,基于红黑树实现的 TreeMap在单线程中的性能表现得并不比跳跃表差。
因此就实现了在非线程安全的 Map 容器中,用 TreeMap 容器来存取大数据;在线程安全的 Map 容器中,用 SkipListMap 容器来存取大数据。

如果对数据有强一致要求,则需使用 Hashtable;在大部分场景通常都是弱一致性的情况下,使用 ConcurrentHashMap 即可;如果数据量在千万级别,且存在大量增删改操作,则可以考虑使用 ConcurrentSkipListMap。

Vector
所有向外暴露的方法都使用了synchronized,因此在读大于写的情况下,会出现大量的锁竞争。

CopyOnWriteArrayList
它实现了读操作无锁,写操作则通过操作底层数组的新副本来实现,是一种读写分离的并发策略。

多线程性能调优_第15张图片多线程性能调优_第16张图片

线程池的设置

在 HotSpot VM 的线程模型中,Java 线程被一对一映射为内核线程。Java 在使用线程执行程序时,需要创建一个内核线程;当该 Java 线程被终止时,这个内核线程也会被回收。因此 Java 线程的创建与销毁将会消耗一定的计算机资源,从而增加系统的性能开销。

线程池

线程池可以提高线程复用,又可以固定最大线程使用量,防止无限制地创建线程。

程序使用一个线程时,先去线程池查找是否有空闲的线程,若有,则直接使用,若没有,判断是否已经超过最大线程量,,如未超过,创建线程,如果超过,进行排队等待或抛出异常。

线程池框架 Executor
这个框架中包括了 ScheduledThreadPoollExecutor 和 ThreadPoolExecutor两个核心线程池。前者是用来定时执行任务,后者是用来执行被提交的任务。

Java线程池和对应使用的阻塞队列
多线程性能调优_第17张图片

ThreadPoolExecutor

多线程性能调优_第18张图片

ThreadPoolExecutor的构造方法

    public ThreadPoolExecutor(int corePoolSize,// 线程池的核心线程数量
                              int maximumPoolSize,// 线程池的最大线程数
                              long keepAliveTime,// 当线程数大于核心线程数时,多余的空闲线程存活的最长时间
                              TimeUnit unit,// 时间单位
                              BlockingQueue workQueue,// 任务队列,用来储存等待执行任务的队列
                              ThreadFactory threadFactory,// 线程工厂,用来创建线程,一般默认即可
                              RejectedExecutionHandler handler) // 拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务

多线程性能调优_第19张图片
默认情况下,线程池中并没有任何线程,等到有任务来才创建线程去执行任务。
但有一种情况排除在外,就是调用 prestartAllCoreThreads() 或者 prestartCoreThread() 方法的话,可以提前创建等于核心线程数的线程数量,这种方式被称为预热。

当创建的线程数等于 corePoolSize 时,提交的任务会被加入到设置的阻塞队列中。当队列满了,会创建线程执行任务,直到线程池中的数量等于maximumPoolSize。

当线程数量已经等于 maximumPoolSize 时, 新提交的任务无法加入到等待队列,也无法创建非核心线程直接执行,如果没有为线程池设置拒绝策略,则直接抛出异常。

当线程池中创建的线程数量超过设置的 corePoolSize,在某些线程处理完任务后,如果等待 keepAliveTime 时间后仍然没有新的任务分配给它,那么这个线程将会被回收。线程池回收线程时,会对所谓的“核心线程”和“非核心线程”一视同仁,直到线程池中线程的数量等于设置的 corePoolSize参数,回收过程才会停止。

我们可以通过 allowCoreThreadTimeOut设置项要求线程池:将包括“核心线程”在内的,没有任务分配的所有线程,在等待 keepAliveTime 时间后全部回收掉。

多线程性能调优_第20张图片

计算线程数量:

一般多线程执行的任务类型可以分为 CPU 密集型和 I/O 密集型

CPU密集型任务
这种任务消耗的主要是 CPU 资源,可以将线程数设置为N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。

I/O 密集型任务
这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。

我们可以参考以下公式来计算线程数:

我们可以通过 JDK 自带的工具 VisualVM 来查看 WT/ST 比例
线程数 =N(CPU 核数)*(1+WT(线程等待时间)/ST(线程时间运行时间))

用协程来优化多线程业务

实现线程主要有三种方式:轻量级进程和内核线程一对一相互映射实现的 1:1 线程模型、用户线程和内核线程实现的 N:1线程模型以及用户线程和轻量级进程混合实现的 N:M 线程模型。

1:1 线程模型
内核线程(Kernel-Level Thread, KLT)是由操作系统内核支持的线程,内核通过调度器对线程进行调度,并负责完成线程的切换。

在 Linux 操作系统编程中,往往都是通过 fork()函数创建一个子进程来代表一个内核中的线程。一个进程调用 fork() 函数后,系统会先给新的进程分配资源,例如,存储数据和代码的空间。后把原来进程的所有值都复制到新的进程中。
所以采用 fork() 创建子进程的方式来实现并行运行,会产生大量冗余数据,即占用大量内存空间,又消耗大量 CPU 时间用来初始化内存空间以及复制数据。

轻量级进程(Light Weight Process,即 LWP)
相对于 fork() 系统调用创建的线程来说,LWP 使用 clone() 系统调用创建线程,该函数是将部分父进程的资源的数据结构进行复制,复制内容可选,且没有被复制的资源可以通过指针共享给子进程。因此,轻量级进程的运行单元更小,运行速度更快。LWP 是跟内核线程一对一映射的,每个 LWP 都是由一个内核线程支持。

N:1 线程模型
1:1 线程模型由于跟内核是一对一映射,所以在线程创建、切换上都存在用户态和内核态的切换,性能开销比较大。除此之外,它还存在局限性,主要就是指系统的资源有限,不能支持创建大量的 LWP。

该线程模型是在用户空间完成了线程的创建、同步、销毁和调度,已经不需要内核的帮助了,也就是说在线程创建、同步、销毁的过程中不会产生用户态和内核态的空间切换,因此线程的操作非常快速且低消耗。

N:M 线程模型
N:1 线程模型的缺点在于操作系统不能感知用户态的线程,因此容易造成某一个线程进行系统调用内核线程时被阻塞,从而导致整个进程被阻塞。
N:M 线程模型是基于上述两种线程模型实现的一种混合线程管理模型,即支持用户态线程通过 LWP 与内核线程连接,户态的线程数量和内核态的 LWP 数量是 N:M 的映射关系。

DK 1.8 Thread.java 中 Thread##start 方法的实现,实际上是通过 Native 调用 start0 方法实现的;在 Linux 下, JVM Thread 的实现是基于 pthread_create 实现的,而 pthread_create 实际上是调用了 clone() 完成系统调用创建线程的。

所以,目前 Java 在 Linux 操作系统下采用的是用户线程加轻量级线程,一个用户线程映射到一个内核线程,即 1:1 线程模型。由于线程是通过内核调度,从一个线程切换到另一个线程就涉及到了上下文切换。

而 Go 语言是使用了 N:M 线程模型实现了自己的调度器,它在 N 个内核线程上多路复用(或调度)M 个协程,协程的上下文切换是在用户态由协程调度器完成的,因此不需要陷入内核,相比之下,这个代价就很小了。

协程的实现原理
我们可以将协程看作是一个类函数或者一块函数中的代码,我们可以在一个主线程里面轻松创建多个协程。
程序调用协程与调用函数不一样的是,协程可以通过暂停或者阻塞的方式将协程的执行挂起,而其它协程可以继续执行。这里的挂起只是在程序中(用户态)的挂起,同时将代码执行权转让给其它协程使用,待获取执行权的协程执行完成之后,将从挂起点唤醒挂起的协程。 协程的挂起和唤醒是通过一个调度器来完成的。

相比线程,协程少了由于同步资源竞争带来的 CPU 上下文切换,I/O 密集型的应用比较适合使用,特别是在网络请求中,有较多的时间在等待后端响应,协程可以保证线程不会阻塞在等待网络响应中,充分利用了多核多线程的能力。而对于 CPU 密集型的应用,由于在多数情况下 CPU 都比较繁忙,协程的优势就不是特别明显了。

Kilim 协程框架
Kilim 框架包含了四个核心组件,分别为:任务载体(Task)、任务上下文(Fiber)、任务调度器(Scheduler)以及通信载体(Mailbox)。

多线程性能调优_第21张图片
Task 对象主要用来执行业务逻辑,我们可以把这个比作多线程的 Thread,与Thread 类似,Task 中也有一个 run 方法,不过在 Task 中方法名为 execute,我们可以将协程里面要做的业务逻辑操作写在 execute 方法中。

与 Thread 实现的线程一样,Task 实现的协程也有状态,包括:Ready、Running、Pausing、以及 Done 总共五种。

Fiber 对象与 Java 的线程栈类似,主要用来维护Task 的执行堆栈,Fiber 是实现 N:M 线程映射的关键。

Scheduler 是 Kilim 实现协程的核心调度器,Scheduler 负责分派 Task 给指定的工作者线程 WorkerThread 执行,工作者线程 WorkerThread 默认初始化个数为机器的CPU个数。

Mailbox 对象类似一个邮箱,协程之间可以依靠邮箱来进行通信和数据共享。协程与线程最大的不同就是,线程是通过共享内存来实现数据共享,而协程是使用了通信的方式来实现了数据共享,主要就是为了避免内存共享数据而带来的线程安全问题。

协程和线程密切相关,协程可以认为是运行在线程上的代码块,协程提供的挂起操作会使协程暂停执行,而不会导致线程阻塞。

使用系统命令查看上下文切换

1.Linux 命令行工具之 vmstat 命令
vmstat 1 3 命令行代表每秒收集一次性能指标,总共获取三次。
在这里插入图片描述
procs
r:等待运行的进程数
b:处于非中断睡眠状态的进程数

memory
swpd:虚拟内存使用情况
free:空闲的内存
buff:用来作为缓冲的内存数
cache:缓存大小

swap
si:从磁盘交换到内存的交换页数量
so:从内存交换到磁盘的交换页数量

io
bi:发送到快设备的块数
bo:从块设备接收到的块数

system
in:每秒中断数
cs:每秒上下文切换次数

cpu
us:用户 CPU 使用事件
sy:内核 CPU 系统使用时间
id:空闲时间
wa:等待 I/O 时间
st:运行虚拟机窃取的时间

2.Linux 命令行工具之 pidstat 命令
查看特定线程的上下文
可以通过命令 yum install sysstat 安装该监控组件。
常用参数:
-u:默认参数,显示各个进程的 cpu 使用情况;
-r:显示各个进程的内存使用情况;
-d:显示各个进程的 I/O 使用情况;
-w:显示每个进程的上下文切换情况;
-p:指定进程号;
-t:显示进程中线程的统计信息

如:通过 pidstat -w -p pid 命令行,我们可以查看到进程的上下文切换:
多线程性能调优_第22张图片
之后,通过 pidstat -w -p pid -t 命令行,我们可以查看到具体线程的上下文切换:

3. JDK 工具之 jstack 命令
查看具体线程的上下文切换异常,可以使用 jstack 命令查看线程堆栈的运行情况。
jstack pid 命令查看线程堆栈信息。
pidstat -p pid -t 一起查看具体线程的状态。

多线程队列

阻塞队列

ArrayBlockingQueue:
基于数组结构实现的有界阻塞队列,按 FIFO(先进先出)原则对元素进行排序,使用 ReentrantLock、Condition 来实现线程安全;

LinkedBlockingQueue:
基于链表结构实现的阻塞队列,同样按 FIFO (先进先出)原则对元素进行排序,使用 ReentrantLock、Condition 来实现线程安全,吞吐量通常要高于 ArrayBlockingQueue;

PriorityBlockingQueue:
具有优先级的无限阻塞队列,基于二叉堆结构实现的无界限(最大值 Integer.MAX_VALUE - 8)阻塞队列,队列没有实现排序,但每当有数据变更时,都会将最小或最大的数据放在堆最上面的节点上,该队列也是使用了 ReentrantLock、Condition 实现的线程安全;

DelayQueue:
支持延时获取元素的无界阻塞队列,基于PriorityBlockingQueue 扩展实现,与其不同的是实现了 Delay 延时接口;

SynchronousQueue:
一个不存储多个元素的阻塞队列,每次进行放入数据时, 必须等待相应的消费者取走数据后,才可以再次放入数据,该队列使用了两种模式来管理元素,一种是使用先进先出的队列,一种是使用后进先出的栈,使用哪种模式可以通过构造函数来指定。

非阻塞队列

ConcurrentLinkedQueue
无界线程安全队列 (FIFO),基于链表结构实现,利用 CAS 乐观锁来保证线程安全。适用于高并发下的排队队列。

构造函数

public ConcurrentLinkedQueue() {
   head = tail = new Node(null);
}

private static class Node {
        volatile E item;
        volatile Node next;
            .
            .
}

入列:当一个线程入列一个数据时,会将该数据封装成一个 Node 节点,并先获取到队列的队尾节点,,当确定此时队尾节点的 next 值为 null 之后,再通过 CAS 将新队尾节点的 next 值设为新节点。此时 p != t,也就是设置 next 值成功,然后再通过 CAS 将队尾节点设置为当前节点即可。

public boolean offer(E e) {
        checkNotNull(e);
        // 创建入队节点
        final Node newNode = new Node(e);
        //t,p 为尾节点,默认相等,采用失败即重试的方式,直到入队成功         
        for (Node t = tail, p = t;;) {
            // 获取队尾节点的下一个节点
            Node q = p.next;
            // 如果 q 为 null,则代表 p 就是队尾节点
            if (q == null) {
                // 将入列节点设置为当前队尾节点的 next 节点
                if (p.casNext(null, newNode)) {
                    // 判断 tail 节点和 p 节点距离达到两个节点
                    if (p != t) // hop two nodes at a time
                        // 如果 tail 不是尾节点则将入队节点设置为 tail。
                        // 如果失败了,那么说明有其他线程已经把 tail 移动过 
                        casTail(t, newNode);  // Failure is OK.
                    return true;
                }
            }
            // 如果 p 节点等于 p 的 next 节点,则说明 p 节点和 q 节点都为空,表示队列刚初始化,所以返回  
            else if (p == q)
                p = (t != (t = tail)) ? t : head;
            else
                // Check for tail updates after two hops.
                p = (p != t && t != (t = tail)) ? t : q;
        }
    }

出列:首先获取 head 节点,并判断 item 是否为 null,如果为空,则表示已经有一个线程刚刚进行了出列操作,然后更新 head 节点;如果不为空,则使用 CAS 操作将 head 节点设置为 null,CAS 就会成功地直接返回节点元素,否则还是更新 head节点。

    public E poll() {
        // 设置起始点
        restartFromHead:
        for (;;) {
            //p 获取 head 节点
            for (Node h = head, p = h, q;;) {
                // 获取头节点元素
                E item = p.item;
                // 如果头节点元素不为 null,通过 cas 设置 p 节点引用的元素为 null
                if (item != null && p.casItem(item, null)) {
                    // Successful CAS is the linearization point
                    // for item to be removed from this queue.
                    if (p != h) // hop two nodes at a time
                        updateHead(h, ((q = p.next) != null) ? q : p);
                    return item;
                }
                // 如果 p 节点的下一个节点为 null,则说明这个队列为空,更新 head 结点
                else if ((q = p.next) == null) {
                    updateHead(h, p);
                    return null;
                }
                // 节点出队失败,重新跳到 restartFromHead 来进行出队
                else if (p == q)
                    continue restartFromHead;
                else
                    p = q;
            }
        }
    }

你可能感兴趣的:(Java性能调优)