02-Java并发底层原理

nobility 发布于 2022-03-18 05-Java并发编程 2028 次阅读


Java并发底层原理

Java内存模型

Java内存交互模型是一组内存操作规范,需要JVM实现来遵守,以便开发者可利用这些规范,更方便的开发多线程程序;该规范不依赖处理器,即时使用不同的处理器结果也是一样的,解决了不同处理器对相同代码解释却不同的问题,从而保证并发安全

若没有该规范,可能在不同的JVM上运行的结果不一样,volatilesynchronizedLock等都将失效,需要我们手动指定内存栅栏(工作内存和主内存之间的拷贝和同步)

重排序

线程内部两行代码时,执行顺序和源代码中的书写顺序不一致,就是发生了重排序,重排序现象是由以下结果导致:

  • 编译器优化:包括JVM,JIT实时编译器等
  • CPU优化:编译器未发生重排序,CPU也可能对指令进行重排序
  • 内存重排现象:由于线程之间的变量不可见性,导致表面上的重排序(其实没有发生重排序)比如:线程1进行修改值,但是未写入主内存时,也就是线程2未看到修改,读出来的是未经修改的值

重排序优化的目的是为了直接允许当前能立即执行的后续指令,避开回去下一条指令所需数据造成的等待,从而提高运行效率

下面代码验证重排序

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) throws InterruptedException {
    while (true) {
      a = b = x = y = 0;
      Thread thread1 = new Thread(() -> {
        a = 1;
        x = b;
      });
      Thread thread2 = new Thread(() -> {
        b = 2;
        y = a;
      });
      thread1.start();
      thread2.start();

      thread1.join();
      thread2.join();
      //保证线程1和线程2先执行结束
      /**
       * 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;
      }
    }
  }
}

可见性

高速缓存容量比主存内存小,但是速度快,所以为了提高执行效率,CPU与主存之间就多了缓存层(不同的CPU有不同的缓存层数),由于CPU有多层缓存,导致读到数据可能会过期,线程之间的共享变量的可见性问题就是由于缓存引起的,Java内存模型标准就屏蔽了底层的多层缓存,只抽象成了两层:主内存和工作内存

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

下面代码验证可见性

public class Main {
  private static int a = 1;
  private static int b = 2;

  public static void main(String[] args) {
    while (true) {
      a = 1;
      b = 2;
      new Thread(() -> {
        try {
          Thread.sleep(100);  //休眠更容易出现该效果
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
        a = 3;
        b = a;
      }).start();
      /**
       * thread1先运行完毕,thread2再运行,                                a=3,b=3
       * thread2先运行完毕,thread1再运行,                                a=1,b=2
       * thread1和2交叉运行,                                              a=3,b=2
       * thread1先运行完毕,但thread2只看到了对b的修改却未看到对a的修改    a=1,b=3
       */
      new Thread(() -> {
        try {
          Thread.sleep(100);  //休眠更容易出现该效果
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
        if (a == 1 && b == 3) {  //条件是a=1,b=3
          System.out.println("a=" + a + ",b=" + b);  //输出结果却可能是其他值
        }
      }).start();
    }
  }
}

Happens-Before

由于操作的有执行顺序,由于操作可以在一个线程之内,也可以是在不同线程之间,所以Java内存模型通过happens-before规则解决跨线程的内存可见性问题,简单的说就是:前一个操作的结果可以被后续操作获取

volatile、synchronized、lock、并非容器、join等待、线程启动等都可以保证可见性,具体Happens-Before规则如下:

  • 单线程原则:又由于传递性,一个线程内,后面语句一定能看到前面语句
  • 锁操作:加锁之后一定能看到解锁之前的指令,以及解锁之前的其他操作
  • volatile变量:使用volatile修饰的变量,所有线程都能立即看到该变量的修改,以及之前的其他操作
  • 线程启动:父线程启动子线程,子线程一定能看到启动前父线程的所有指令
  • 线程join:线程join等待结束后,一定能看到被等待线程的所有指令
  • 中断:线程被其他线程中断,中断检测或中断异常一定能被其他线程看到
  • 构造方法:finalize()方法一定能看到构造方法中的最后一条指令
  • 工具类的Happens-Before原则
    • 线程安全容器get一定能看到在此之前的put等存入动作
    • CountDownLatch、CyclicBarrier、Semaphore
    • Future
    • 线程池

volatile关键字

volatile的作用

volatile只能作用于属性字段上

volatile是一种轻量级的同步机制,因为并不会发生上下文切换等行为开销(无锁机制),相应的能力也小,无法做到synchronized那样的原子保护,只有在很有限的场景下才能发挥作用

  • 可见性:读取一个volatile变量之前,需要先适相应的本地缓存失效,这样就一定能从主内存中读取到最新的值;修改一个volatile变量会立即刷新到主内存中
  • 禁止指令重排序优化:比如解决单例双重锁乱序问题

不适用的场合

非原子性的数据争用

public class Main {
  private volatile static int a;

  public static void main(String[] args) throws InterruptedException {
    Runnable runnable = () -> {
      for (int i = 0; i < 10000; i++) a++;
    };
    Thread thread1 = new Thread(runnable);
    Thread thread2 = new Thread(runnable);
    thread1.start();
    thread2.start();
    thread1.join();
    thread2.join();
    //保证线程1和线程2先执行结束
    System.out.println("最终的a=" + a);  //结果不一定是20000
  }
}

适用场合

纯赋值的原子操作,由于Happens-Before规则,volatile可作为刷新之前变量的触发器

public class Main {
  private volatile static boolean flag;
  private static String options;

  public static void main(String[] args) throws InterruptedException {
    Thread thread1 = new Thread(() -> {
      System.out.println("读取配置文件");
      options = "已赋值配置";
      System.out.println("读取配置成功");
      flag = true;  //由于Happens-Before规则,另一个线程也一定会看到options的修改,从而保证线程安全
    });
    Thread thread2 = new Thread(() -> {
      while (!flag) {  //未配置好就陷入休眠1秒
        try {
          Thread.sleep(1000);
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
      }
      System.out.println("配置项为:" + options);
    });
    thread1.start();
    thread2.start();
  }
}

对比synchronized

volatile相对于synchronized更轻量,如果一个共享变量至始至终只被各个线程赋值,而没有其他操作,那么就可以使用volatile来代替synchronized以及原子变量,因为赋值本身就具有原子性,而volatile又保证了可见性,所以就保证了线程安全

原子性

原子性:一系列操作,要么全部执行成功,要么全部不执行,不会出现执行一半的情况,是不可分隔的

比如i++操作其实就是:取出i的值,加1,赋回来i的值,这是三步操作,不是原子性的,可使用synchronized实现原子行

原子性操作

  • 除long和double之外的基本类型赋值操作都是原子操作
  • 所有引用类型的赋值操作都是原子操作,无论是32位还是64位机器
  • java.concurrent.Atomic.*包中的所有类都是原子类

long和double的原子性

由于long和double是64位的值,所以在32位机器上会被视为两次单独的写入,每次写32位;64为位的JVM上是原子的

  • Oracle官方鼓励JVM在实现时,避免拆分64位值
  • Oracle官方鼓励程序员将其使用volatile修饰或进行同步,以防止JVM在实现时为实现上述内容

原子操作的组合

以下操作并不能一定保证线程安全

synchronized (this){
  //...
}
synchronized (this){
  //...
}

需要再套一个,来保证线程安全

synchronized (this){
  synchronized (this){
    //...
  }
  synchronized (this){
    //...
  }
}
加油啊!即便没有转生到异世界,也要拿出真本事!!!\(`Δ’)/
最后更新于 2022-03-18