Java高并发之Fork/Join框架、锁优化

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接: https://blog.csdn.net/u014454538/article/details/98469207

1. Fork/Join框架

① 什么是Fork/Join框架
  • Fork/Join框架是JDK1.7提供的一个并行任务执行框架,它可以把一个大任务分成多个可并行执行子任务,然后合并每个子任务的结果,得到的大任务的结果。
  • 有点类似Hadoop的MapReduce,Fork/Join框架也可以分成两个核心操作:
  1. Fork操作:将大任务分割成若干个可以并行执行的子任务
  2. Join操作:合并子任务的执行结果
  • 计算1 + 2 + 3 + ... + 999999 + 1000000,可以将其通过Fork操作分割成若干个适合计算的子任务(子任务也可以分割成子任务),例如每个子任务负责计算100个数的sum,然后通过Join操作合并子任务的执行结果。
    在这里插入图片描述
    Fork/Join框架的运行流程
② 工作窃取算法
  • 工作窃取算法的由来:
  1. 在Fork/Join框架中,切割出来的子任务会被放到不同的队列中,分别为每个队列创建一个单独线程,由该线程负责队列中子任务的执行。例如,A线程负责执行A队列中的子任务,B线程负责执行B队列中的子任务。
  2. 由于不同的线程,执行子任务的快慢不同,可能A线程先执行完A队列中的子任务。
  3. 这时,A线程与其等着,不如帮其他线程执行子任务。于是,A线程从其他队列中窃取一个子任务来执行。
  • 工作窃取算法: 线程从其他队列中窃取任务来执行。
  • 双端队列解决线程竞争:
  1. 当窃取线程从其他队列中拿任务时,会和被窃取线程访问同一个队列,这样会产生竞争。
  2. 为了避免窃取线程和被窃取线程之间的竞争,使用双端队列存储子任务
  3. 窃取线程从双端队列的尾部拿任务来执行,被窃取线程从双端队列的头部拿任务来执行
    在这里插入图片描述
    工作窃取运行流程图
  • 工作窃取算法的缺点:
  1. 在双端队列中只有一个任务时,工作窃取算法还是会存在竞争
  2. 工作窃取算法消耗了很多的资源,比如创建多个线程和双端队列。
③ Fork/Join框架的设计
  • 根据分析,Fork/Join框架应该具有三个流程:
  1. 分割任务: 提供一个能将大任务分割成子任务的方法,而且支持细粒度分割,即子任务可以继续分割成更小的子任务。
  2. 子任务的并行执行: 分割好的子任务会被放到不同的双端队列中,为每个队列创建一个单独的线程,然后由这些线程来完成子任务的并行执行。
  3. 合并子任务的结果: 每个子任务执行的结果放到同一个队列中,由一个线程完成这些结果的合并。
  • 根据以上分析,Fork/Join框架有两个核心类: ForkJoinTask类和ForkJoinPool类。
  • ForkJoinTask类:
  1. 提供fork()方法和join()方法,分别对应Fork操作和Join操作。
  2. 一般我们不需要继承ForkJoinTask类,而是继承它的子类RecursiveTaskRecursiveAction来自定义任务。
  3. ForkJoinTask类:用于有返回结果的任务;RecursiveAction类:用于没有返回结果的任务。
  • ForkJoinPool类:用于执行ForkJoinTaskExecutorService
  • 编程实例: 实现1~20的求和,每个子任务的大小为5。
public class SumTask extends RecursiveTask<Integer> {
    private static final int THRESHOLD = 5;
    private int start;
    private int end;
    public SumTask(int start, int end) {
        this.start = start;
        this.end = end;
    }
    @Override
    protected Integer compute() {
        int sum = 0;
        if (start - end <= THRESHOLD) {
            for (int i = start; i <= end; i++) {
                sum += i;
            }
        } else {
            // 任务超过阈值,分割成两个子任务
            int mid = (start + end) / 2;
            SumTask leftTask = new SumTask(start, mid);
            SumTask rightTask = new SumTask(mid + 1, end);
            // 每个子任务分别调用fork方法, 会又进入compute方法。
            // 因此会不断分割子任务,知道不超过阈值
            leftTask.fork();
            rightTask.fork();
            // 获取每个子任务的执行结果
            int leftSum = leftTask.join();
            int rightSum = rightTask.join();
            sum = leftSum + rightSum;
        }
        return sum;
    }
    public static void main(String[] args) {
        // 创建一个任务
        SumTask task = new SumTask(1, 20);
        // 执行任务并获取结果
        ForkJoinPool pool = new ForkJoinPool();
        Future<Integer> result = pool.submit(task);
        try {
            System.out.println("1~20求和的结果为:" + result.get());
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        } catch (ExecutionException e2) {
            e2.printStackTrace();
        }
    }
}
④ Fork/Join框架的异常处理
  • ForkJoinTask在执行任务时,通过isCompletedAbnormally()方法获取任务状态。该方法在任务抛出异常或者被取消的情况下,返回false
public final boolean isCompletedAbnormally() {
    return status < NORMAL;
}
  • 如果任务抛出异常或者被取消了,可以通过getException()方法获取具体的异常信息。如果被取消了,则返回CancellationException异常。
if (task.isCompletedAbnormally()){
    System.out.println(task.getException());
}
  • 任务的四种状态:
  1. NORMAL:必须为一个负数,表示任务已经完成
  2. CANCELLED:必须小于NORMAL的值,表示任务被取消
  3. EXCEPTIONAL:必须小于CANCELLED的值,表示任务出现异常
  4. SIGNAL:必须大于等于1 << 16表示信号
⑤ Fork/Join框架的具体实现
  • ForkJoinPool包含ForkJoinTask数组和ForkJoinWorkerThread数组,ForkJoinTask数组用于存放提交的任务ForkJoinWorkerThread数组负责异步执行任务,然后返回结果。
  • ForkJoinTaskjoin()方法中,通过调用doJoin()方法获取任务的状态,然后根据任务的状态返回不同的结果。
  1. NORMAL: 返回任务的执行结果
  2. CANCELLED: 返回CancellationException异常。
  3. EXCEPTIONAL: 返回对应的异常。

2. 锁优化

  • 锁优化技术是为了在线程之间更高效的共享数据更好的解决竞争问题,从而提高程序的执行效率。
① 自旋锁和自适应的自旋锁
  • 为什么要设计自旋锁?
  1. 互斥同步时,对性能影响最大的是阻塞。因为线程的挂起和恢复需要由内核态完成,这会影响操作系统的并发性能。
  2. 而且在许多应用中,共享数据的锁定状态只会持续很短的时间,如果为了这段时间去挂起和恢复线程并不值得。
  3. 在多CPU的情况下,一个线程在请求已经被锁定的共享数据时,不是立即进入阻塞状态而是持有CPU时间进行等待
  4. 为了实现等待,需要线程执行忙循环(自旋) 一段时间。如果线程能在自旋时间内获取到锁,就能避免进入阻塞状态。
  5. 将这种自旋等待的技术,称为自旋锁
  • 自旋锁在JDK1.4中就出现了,但默认关闭的,在JDK1.6中改为默认开启。
  • 自旋等待本身虽然避免了线程的切换,但是会占用CPU时间。如果锁被占用的时间很短,则自旋等待的效果就会非常好;如果锁被占用的时间很长,则会自旋等待会白白浪费CPU资源。
  • 因此,需要为自旋锁设定自旋等待时间的上限。如果超过上限值线程应该按照传统方法被挂起
  • 自旋等待的时间用自旋次数衡量,默认值为10,可以通过-XX:PreBlockSpin来更改自旋次数。
  • 在 JDK 1.6 中引入了自适应的自旋锁。自适应意味着自旋的次数不再固定了,而是由前一次在同一个锁上的自旋次数锁的拥有者的状态来决定。
  1. 在同一个锁上,自旋刚刚成功获取过锁,且持有锁的线程正在运行。可以认为这次自旋也很有可能成功,可以让其自旋等待的时间长一点
  2. 对于某个锁,自旋很少成功过,以后要获取这个锁时很有可能省略自旋过程
② 锁消除
  • Java的字节码的执行采用混合模式: 解释执行 + 即时编译JIT,just in time
  1. 解释执行:逐条将字节码翻译成机器码
  2. 即时编译:将频繁运行的方法或代码块(统称为热点代码)对应的字节码一次性翻译成机器码,并进行各种层次的优化。
  • 逃逸分析是JVM最前沿的编译器优化技术之一,它不是直接优化代码的手段,而是为其他优化技术提供依据的分析技术
  1. 逃逸分析的基本行为就是分析对象的动态作用域
  2. 如果一个对象在方法中被定义,可能被其他方法所引用,例如调用时的参数传递,称为方法逃逸
  3. 一个对象还可能被其他线程所访问,例如赋值给类变量或者可以被其他线程访问的实例变量,称为线程逃逸
  • 锁消除是指在即时编译器运行时,对于代码上要求同步,但被检测到不可能存在共享数据竞争的锁进行消除。
  • 锁消除的主要判定依据来自于逃逸分析:如果一段代码中,堆上所有的数据都不会发生线程逃逸,则可以将它们当做栈中的数据对待,认为它们是线程私有的。这样,同步加锁自然无须进行。
  • 很多同步措施是Java自行添加的,进行锁消以除提高程序执行效率是有必要的。
  • 下面的一段代码,明显不存在同步。
public String concatString(String s1,String s2,String s3){
    return s1+s2+s3;
}
  • javac编译器会对String的连接进行自行优化:JDK1.5之前,将其转化为StringBufferappend()操作;JDK1.5及以后,会将其转化为StringBuilderappend()操作。
public String concatString(String s1,String s2,String s3){
    StringBuffer sb=new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}
  • StringBufferappend()方法是同步方法,会对sb对象加锁。但是通过逃逸分析发现,sb对象不会逃出concatString()方法之外,其他线程无法访问他该对象。因此,即时编译器编译后,这段代码就可以忽略所有的同步而直接执行。
③ 锁粗化
  • 在编写代码时,总是将同步代码块的范围限制的尽量小,即只在共享数据的实际作用域中才进行同步。这样使得需要同步的操作数量尽可能少,在出现锁竞争时,等待锁的线程可以尽早获取锁
  • 注意: 频繁的进行加锁解锁,反而会导致性能损失。
  • 锁粗化:如果一串零碎的操作都对同一对象加锁,则可以将锁的范围扩展(粗化)到整个操作序列的外部
  • 上面一连串的append()操作,都是同步操作,可以将锁的范围扩展到第一个append()操作之前、最后一个append()操作之后。
④ 轻量级锁
  • 锁存储在Java的对象头中,如果是数组对象,对象头长度为3 Word;如果是非数组对象,长度为2 Word
  1. Mark Word: 存储对象的hashCode、分代年龄(GC age)、锁等信息
  2. Class Metadata Address: 存储到对象数据类型的指针
  3. Array Length: 数组长度
  • Mark Word最后2 bit 固定为标志位(flag bits),用标识一些特殊的状态。
存储的内容 标志位 状态
指向锁记录(Lock Record)的指针 00 轻量级锁定
hashCode、GC分代年龄、是否偏向锁0(1 bit) 01 未锁定
指向重量级锁的指针 01 重量级锁定
偏向线程id、偏向时间戳(epoch)、GC分代年龄、是否偏向锁1(1 bit) 10 可偏向
内容为null,无需记录任何信息 11 GC标记
  • 锁状态从低到高为:未锁定、可偏向、轻量级锁定、重量级锁定,锁可以升级不能降级。
  • 轻量级锁的加锁过程:
  1. 进入同步块的时候,如果同步对象没有被锁定(处于0 + 01状态),JVM首先在当前线程的栈帧中创建一个名为锁记录Lock Record)的空间,用于存储对象的Mark Word的拷贝。
  2. 为了加以区别,栈帧中的Mark Word的拷贝,叫做Displaced Mark Word
  3. JVM尝试通过CAS操作将对象的Mark Word更新为指向锁记录的指针
  4. 如果更新成功,当前线程就获取了该对象的锁,并且对象的Mark Word的标志位变为00,即该对象处于轻量级锁定状态。
  5. 如果更新失败,JVM首先会检查对象的Mark Word是否指向当前线程的栈帧。如果指向,表明线程已经获取了该对象的锁,可以直接进入同步块执行操作。
  6. 如果有两个以上的线程争用同一个锁,轻量级锁要膨胀成重量级锁,并且不会恢复到轻量级锁。锁标识位变为10Mark Word存储的是指向重量级锁的指针
    在这里插入图片描述
  • 轻量级锁的解锁过程:
  1. 如果Mark Word仍然指向线程的栈帧,则通过CAS操作将对象的Mark Word和线程栈帧中的Displaced Mark Word替换回来。
  2. 如果替换成功,整个同步过程完成;否则,说明有其他线程尝试过获取锁,当前线程在释放锁的同时,唤醒被阻塞的线程
  • 轻量级锁提升性能的依据:对于绝大部分锁,整个同步周期内是不存在竞争的
  1. 如果不存在锁竞争,轻量级锁使用CAS操作,避免了使用互斥量的开销。
  2. 如果存在锁竞争,不仅有互斥量的开销,还有CAS操作的开销,轻量级锁会比重量级锁更慢
  • JDK1.6中引入了轻量级锁偏向锁,两项锁优化技术。
⑤ 偏向锁
  • 偏向锁的目的: 消除无竞争情况下的同步原语。
  • 偏向锁的思想: 偏向于第一个获取它的线程,如果在接下来的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程将不需要再进行同步操作,甚至链CAS操作都不需要。
  • 偏向的锁获取:
  1. 偏向锁第一次被线程获得时,进入可偏向状态,标志位为1 + 01
  2. 通过CAS操作将该线程的thread id记录到对象Mark Word中。
  3. 如果CAS操作成功,这个线程以后每次进入锁相关的同步块都可以不用执行任何同步操作(lockunlockMark Word的更新等)。
  • 偏向锁的释放:
  1. 另外一个线程尝试获取已经被占有的偏向锁,可偏向状态结束。
  2. 撤销偏向(Revoke Bias)后,恢复到未锁定或者轻量级锁定状态
    在这里插入图片描述
  • 注意:
  1. JDK1.6中偏向锁是默认开启的,通过-XX:+UseBiasedLocking开启偏向锁,通过-XX:-UseBiasedLocking关闭偏向锁。
  2. 偏向锁是为了提高带有同步但无竞争的程序性能,并不一定总是对程序有利。
  3. 当程序中的大多数锁存在多线程访问时,偏向锁是多余的,可以通过参数-XX:-UseBiasedLocking关闭偏向锁。

猜你喜欢

转载自blog.csdn.net/u014454538/article/details/98469207