07-高效并发

nobility 发布于 2021-07-17 1737 次阅读


高效并发

Java内存模型

JCP组织定义了一种Java内存模型,以前是在JVM规范中,后来独立出来成为JSR-133(Java内存模型和线程规范修订)

内存模型就是在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象,简单的说就是怎么样跟内存进行交互,怎么样读写内存,Java内存模型主要关注JVM中将变量值存储到内存和从内存中取出变量值这样的底层细节

Java中所有的共享的变量都存储在主内存中,每个线程都有自己的工作内存,工作内存中保存该线程使用到的变量的主内存副本拷贝;线程对变量的读写操作都应该在工作内存中完成;不同线程之间不能相互访问工作内存,交互数据需要通过主内存

Java内存间的交互操作

Java内存模型规定了一些操作来实现内存间交互(就是一个变量从主内存拷贝到工作内存,从工作内存同步回主内存的细节),JVM会保证他们是原子的,具体包含如下几个操作:

  • lock:锁定,作用于主内存变量,将变量标识为线程独占
  • unlock:解锁,作用于主内存变量,将锁定的变量释放,别的线程才能使用
  • read:读取,将变量从主内存读入到工作内存中(并没有赋值到工作内存的副本变量上)
  • load:载入,将read读取到的值放入工作内存的变量副本中(赋值给工作内存的副本变量)
  • use:使用,将工作内存中的一个变量的值传递给执行引擎
  • assign:赋值,将从执行引擎接收到的值赋给工作内存中的变量
  • store:存储,将工作内存中的变量的值传递到主内存中(同样的,并没有赋值到主内存变量上)
  • write:写入,将store进来的数据存入主内存的变量中

内存间交互操作的具有如下规则:

  • 不允许read和load、store和write操作之一单独出现,以上两个操作必须按照顺序执行,但中间可插入其他操作
  • 不允许一个线程丢弃他的最近的assign操作,即变量在工作内存中发生改变后必须将其同步回主内存
  • 不允许一个线程无原因(没有assign操作)的将数据从线程工作内存同步回主内存,即变量在工作内存中未发生任何改变
  • 一个公共变量只能从主内存中诞生,不允许在工作内存中直接使用一个未初始化的变量,也就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作
  • 一个变量在一个时刻只允许一条线程对其执行lock操作,但lock操作可以被同一个线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁
  • 若对一个变量执行lock操作,将会清空工作内存中此变量的值(上次交互遗留的相同变量),在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值
  • 若一个变量没有被lock操作锁定,则不允许对他执行unlock操作,也不能unlock一个被其他线程锁定的变量
  • 对一个变量执行unlock操作之前,必须先将此变量同步回主内存,也就是先执行store和write操作

volatile关键字

当一个线程修改了变量,其他线程也可感知到,常用的方式有:volatile、synchronized、final(一旦初始化完成,其他线程就可感知)

适合使用volatile关键字的场景有:运算结果不依赖变量的当前值,或者能确保只有一个线程修改该变量的值

volatile具有如下特性:

  • 基本是是JVM提供的最轻量级的同步机制,用volatile修饰的变量,对其他的线程可见,即对volatile变量所作修改其他线程能立刻感知
  • volatile修饰的变量是禁止指令重排序优化的
  • 但多线程环境下仍然是线程不安全的,下面程序可进行测试,会发现每次最终输出结果不一样
class Model {
  private volatile int i;

  public /*synchronized*/ void add() {  //加上synchronized后就可以达到线程安全的目的
    i++;
  }

  public int getI() {
    return i;
  }
}

class MyThread implements Runnable {
  private Model model;

  public MyThread(Model model) {
    this.model = model;
  }

  @Override
  public void run() {
    for (int i = 0; i < 1000; i++) {
      model.add();
    }
    System.out.println(Thread.currentThread().getName() + "结束");
  }
}

public class Main {
  public static void main(String[] args) {
    Model model = new Model();
    Thread thread1 = new Thread(new MyThread(model));
    Thread thread2 = new Thread(new MyThread(model));
    thread1.start();
    thread2.start();
    try {
      thread1.join();
      thread2.join();
      //保证线程1和线程2先执行结束
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
    System.out.println("最终的i=" + model.getI());
  }
}

指令重排序

指的是JVM为了优化,在条件允许的情况下,对指令流进行一定的重排序,目的是为了直接允许当前能立即执行的后续指令,避开回去下一条指令所需数据造成的等待

线程内存串行语义,不考虑多线程间的语义(线程内有序,线程间无序),并不是所有的指令都能重排,比如:

  • 写后读:写一个变量后,再读这个位置,比如a=1;b=a
  • 写后写:写一个变量后,再写这个变量,比如a=1;b=2
  • 读后写:读一个变量后,再写这个变量,比如a=b;b=1

在Java中指令重排还有以下规则:

  • 程序顺序原则:一个线程内保证语义的串行性
  • volatile原则:volatile变量的写,先发生于读
  • 锁规则:解锁必然发生在锁后的加锁前
  • 传递性:A先于B,B先于C那么必然A先于C
  • 线程的start()方法先于任何动作
  • 线程内的所有操作先于线程的终结,比如说Thread.join()方法
  • 线程的中断(interrupt()方法)先于被中断线程的代码
  • 对象的构造函数执行结束先于finalize()方法

下面代码可证明有重排序

public class Main {
  private static int a = 0;
  private static int b = 0;
  private static int x = 0;
  private static int y = 0;

  public static void main(String[] args) {
    while (true) {
      a = b = x = y = 0;
      Thread thread1 = new Thread(() -> {
        a = 1;
        x = b;
      });
      thread1.start();
      Thread thread2 = new Thread(() -> {
        b = 2;
        y = a;
      });
      thread2.start();

      try {
        thread1.join();
        thread2.join();
        //保证线程1和线程2先执行结束
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
      /**
       * thread1先运行完毕,thread2再运行,                    a=1,b=2,x=0,y=1
       * thread2先运行完毕,thread1再运行,                    a=1,b=2,x=2,y=0
       * thread1和2交叉运行,thread1部分运行,thread2完整运行,a=1,b=2,x=2,y=1
       * thread1和2交叉运行,thread1部分运行,thread2部分运行,a=1,b=2,x=2,y=1
       * thread1和2交叉运行,thread2部分运行,thread1完整运行,a=1,b=2,x=2,y=1
       * thread1和2交叉运行,thread2部分运行,thread1部分运行,a=1,b=2,x=2,y=1
       * 出现重新排序,可能会出现                              a=1,b=2,x=0,y=0
       */
      System.out.println("a=" + a + ",b=" + b + ",x=" + x + ",y=" + y);
      if (x == 0 && y == 0) {
        System.out.println("证实了有重排序");
        break;
      }
    }
  }
}

线程安全的处理方法

  • 不可变的一定是线程安全的
  • 互斥同步(阻塞同步):synchronized、java.util.concurrent.ReentrantLock;目前这两种方式性能已经差不多,建议优先选择synchronized,ReentrantLock还增加了如下特性
    • 等待可中断:当持有锁的线程长时间不释放锁,正在等待的线程可以选择放弃等待
    • 公平锁:多个线程等待同一个锁时,必须严格按照申请锁的时间顺序来获得锁
    • 锁绑定多个条件:一个ReentrantLock对象可以绑定多个condition对象;而synchronized是针对一个条件的,如果要多个,就得有多个锁
    • 非阻塞同步:是一种基于冲突检查的乐观锁定策略,通常是先操作,若没有冲突,操作就成功了,有冲突再采取其他方式进行补偿处理
    • 无同步方案:其实就是在多线程中,方法并不涉及共享数据,自然也就无需同步了

锁优化

自旋锁和自适应自旋锁

自旋:若线程可以很快获得锁,JVM就可以不在OS层挂起线程,而是让线程做几个忙循环

自适应的自旋:自旋时间不再固定,而是由前一个在同一个锁上的自选时间和锁的拥有者状态来决定

整个优化过程由JVM自动完成,无需人工干预,若锁被占用时间很短,自旋成功,那就节省了线程挂起以及切换时间,从而提升系统性能;若锁占用时间很长,自旋失败,就会浪费处理器资源,从而降低系统性能

锁消除

在编译代码时,检测到根本不存在共享数据竞争,也就无需同步加锁了,通过-XX:+EliminateLocks来开启,同时要使用-XX:+DoEscapeAnalysis开启逃逸分析

逃逸,其实就是方法内部东西被外界访问到了,分为以下两种情况

  • 若一个方法中定义了一个对象,可能被外部方法引用,称为方法逃逸
  • 若一个对象可能被其他外部线程访问,称为线程逃逸,比如:赋值给类变量或可以在其他线程中访问的实例变量

一旦存在逃逸,就意味着不能轻易将锁消除,无法知道外部是如何使用这些资源的

锁粗化

通常需要将同步块尽可能的小,但一系列连续的操作导致对一个对象反复的加锁和解锁,这会导致不必要的新能损耗,这种情况建议将锁同步的访问加大到整个操作序列

轻量级锁

轻量级锁是相对于传统锁机制而言的,本意是没有多线程竞争的情况下,减少传统锁机制使用OS实现互斥产生的性能损耗,就是类似于乐观锁的方式

若轻量级锁失败了,就表示存在竞争,从而又升级为重量级锁,导致性能下降

偏向锁

偏向锁,也就是偏心,锁会偏向于当前已经占有锁的线程,在无竞争的情况下,直接将整个同步消除了(包括乐观锁),从而提高性能

只要没有竞争,获取偏向锁的线程,在将来进入同步块,也无需做同步,当其他线程请求相同的锁时,偏向模式结束,也就是说若程序中大多数锁总是被多个线程访问时候,也就是竞争比较激烈,偏向锁反而会降低性能,使用-XX:-UseBiasedLocking参数来禁用偏向锁,默认是开启的

JVM获取锁的步骤

  1. 会优先尝试偏向锁
  2. 再尝试轻量级锁
  3. 再尝试自旋锁
  4. 最后尝试普通锁,使用OS互斥量在OS层挂起

使用同步代码的基本规则

  • 尽量减少锁持有的时间
  • 尽量减少锁的粒度
此作者没有提供个人介绍
最后更新于 2021-07-17