【Java】并发编程三大特性

目录

一、可见性(visibility)

1.用 volatile 保障可见性

2.某些语句会触发可见性机制

二、有序性(ordering)

1.语句乱序执行的现象

2.this 对象逸出的现象

3.阻止乱序的方式:内存屏障

三、原子性(atomicity)

1.什么是原子性操作?

2.如何保证操作的原子性?

(1)悲观锁

(2)乐观锁

(3)总结

本文为个人学习笔记,一切内容仅供参考。

一、可见性(visibility)

1.用 volatile 保障可见性

可见性:

  • 被 volatile 修饰的内存,对所有线程可见。

  • 线程每一次使用被 volatile 修饰的变量时,都需要去内存中重新读取一遍该变量的值。

代码:

public class Main {     
  private static volatile boolean running = true;     
  
  private static void m() {         
    System.out.println("m start!");         
    while (running) {}         
    System.out.println("m end!"); 
  }      
  
  public static void main(String[] args) throws InterruptedException {         
    new Thread(() -> {             
      Main.m(); 
    }, "t1").start(); 
    
    TimeUnit.SECONDS.sleep(1); 
    
    running = false; 
  } 
} 

在上例中,如果 running 不用 volatile 修饰,则在 main 线程中修改其值后,并不会影响 t1 线程中 copy 到的 running 的值,线程会一直循环运行。

volatile 修饰引用类型(包括数组)只能保证引用本身的可见性,不能保证内部字段(成员变量)的可见性。

2.某些语句会触发可见性机制

某些语句内部带有 synchronized,可以触发可见性,如:println()。

这些语句会触发内存缓存同步刷新:可以将线程本地的缓存和主内存之间的数据进行刷新与同步。

二、有序性(ordering)

1.语句乱序执行的现象

举例:《【Java】并发编程三大特性》

  • 提高 CPU 的执行效率:CPU 的处理速度远大于寄存器,当指令 1 去读取数据时,需要等待很长的时间,这个时候 CPU 就可能会先去执行指令 2 做计算。

  • 前提:前后两条语句没有依赖关系,如:x = 1;  x++; 就不会发生乱序。

  • as-if-serial:不论在计算机底层实际是谁先执行,都不会影响单线程的最终一致性。

如果在多线程环境下发生乱序,则可能会造成一些不好的影响。

代码:

public class Main {     
  private static boolean ready = false;     
  private static int number;     
  
  private static class MyThread extends Thread {         
    @Override 
    public void run() {             
      while (!ready) {                 
        Thread.yield(); 
      }             
      System.out.println(number); 
    }     
  }      
  
  public static void main(String[] args) {         
    Thread t = new MyThread(); 
    t.start(); 
    // 下面这两条语句没有依赖关系,可能会发生乱序执行 
    number = 42; 
    ready = true; 
  } 
} 

分析:

  • 在类初始化时,会先给静态变量赋初值,即 number = 0。

  • 若发生了语句的乱序执行,即若 ready = true 先执行,则此时的 number 还没被初始化就会被打印。

  • 最后打印的 number 可能是 42 或 0。

2.this 对象逸出的现象

代码:

public class Main {     
  private int num = 8;     
  
  public Main() {         
    new Thread(() -> {             
      System.out.println(num); 
    }).start(); 
  }      
  
  public static void main(String[] args) { 
    new Main(); 
  } 
}

分析:

  • 在对象初始化的过程中,可能会先将对象(this)与内存空间建立关联,再调用构造方法对成员变量进行初始化。

  • 在构造方法还未执行完时,可能会先启动线程,然后打印输出还未初始化的变量值 num = 0。

  • 对象的创建过程详见:。

解决方法:不要在构造方法中启动线程(但是可以创建线程),可以单独写一个方法用于启动线程。

3.阻止乱序的方式:内存屏障

内存屏障的含义:是一种特殊的指令,其前面的指令必须执行完,后面的才能执行。

JVM 内存屏障:《【Java】并发编程三大特性》

volatile 底层使用内存屏障实现:

  • volatile 读 = 读数据 + LoadLoadBarrier + LoadStoreBarrier

  • volatile 写 = LoadStoreBarrier + 写数据 + StoreLoadBarrier

volatile:

  • 使用 volatile 可以保证可见性

  • 使用 volatile 可以保证有序性

三、原子性(atomicity)

1.什么是原子性操作?

原子性操作:不能被其它线程打断,不能与其它线程一起并发执行的操作。

代码:

public class Main {     
  private static long n = 0L;     
  
  public static void main(String[] args) throws InterruptedException {         
    Thread[] t = new Thread[100]; 
    CountDownLatch latch = new CountDownLatch(t.length);         
    
    for (int i = 0; i < t.length; i++) {             
      t[i] = new Thread(() -> {                 
        for (int j = 0; j < 10000; j++) {                     
          n++; 
        }                 
        latch.countDown(); 
      }); 
    }          
    
    for (int k = 0; k < t.length; k++) {             
      t[k].start(); 
    }          
    
    // 阻塞,等待所有线程执行完毕后再打印n的值 
    latch.await(); 
    System.out.println(n); 
  } 
} 

理论结果为:1 000 000。

实际结果为:122 507。

原因:多个线程访问同一个共享数据时会产生竞争,可能会造成数据的不一致(并发访问之下产生的不期望出现的结果)。

如何解决这个问题?

synchronized 上锁保证原子性

  • 可以将 n++ 这条语句用 synchronized(Main.class) 上锁来实现线程同步(保障数据一致性)。

  • 上锁的本质就是把并发编程序列化。

  • 用 synchronized 也可以保证可见性,上例中每次 n++ 完以后,一定会与主内存做同步。

  • 用 synchronized 不能保证有序性,其内部的语句仍然可能出现乱序重排的现象。

2.如何保证操作的原子性?

(1)悲观锁

含义:悲观地认为这个操作会被别的线程打断。

(2)乐观锁

含义:乐观地认为这个操作不会被别的线程打断,又叫做无锁、自旋锁(使用 CAS 实现)。

CAS(compare and swap / exchange / set)

CAS 原理:《【Java】并发编程三大特性》

  • 当这里的值 E、V 为普通的数据类型时,不用考虑 ABA 问题;但如果是引用类型,则需要加 version 来解决 ABA 问题。

  • version 可以由带时间戳、数字或布尔类型变量等方式来实现。

  • CAS 自身必须保证原子性。

代码:

public class Main {     
  // 设置初始值为0 
  AtomicInteger count = new AtomicInteger(0);  
  
  static CountDownLatch latch = new CountDownLatch(100);
  
  void run() {         
    for (int i = 0; i < 10000; i++) {             
      // count++ 
      count.incrementAndGet(); 
    }         
    latch.countDown(); 
    System.out.println(Thread.currentThread().getName() + ":" + count); 
  }      
  
  public static void main(String[] args) throws InterruptedException {       
    Main m = new Main(); 
    List<Thread> t = new ArrayList<>();         
    
    for (int i = 0; i < 100; i++) {             
      t.add(new Thread(m::run, "thread-" + i)); 
    }          
    // 启动所有线程
    t.forEach((o) -> o.start()); 
    
    latch.await(); 
    System.out.println(m.count); // 1000000 
  } 
}

分析:

  • AtomicInteger 可以保证原子性,用于对 int 类型的数据进行原子性的访问,此时的访问是加了锁的。

  • AtomicXxx:通过 CAS 实现,一般会比直接用 synchronized 上锁效率更高。

  • incrementAndGet():实现线程安全的自增(原子性操作)。

  • 其他保证原子性的类:AtomicLong、LongAdder(效率:LongAdder > AtomicLong > Sync)。

CAS 底层实现:

  • 由 CPU 原语(指令级别)支持。

  • 底层还是通过上锁实现的(汇编指令:lock cmpxchg),不允许其他 CPU(线程)打断当前对某块内存的 CAS 操作(单核 CPU 没必要上锁)。

(3)总结

基本概念补充:

critical section(临界区,即被锁住的代码):

  • 若临界区的执行时间长,语句多,就叫锁的粒度比较粗。

  • 若临界区的执行时间短,语句少,就叫锁的粒度比较细。可以使线程争用时间变短,从而提高效率。

悲观锁与乐观锁的效率对比:

悲观锁:

  • 有一个等待队列,等待中的线程不消耗 CPU 的资源。

  • 适用场景:临界区执行时间长,且等待线程很多。

乐观锁:

  • 所有线程会一直循环地去访问数据,这些线程都是活着的,需要线程间的调度,会消耗 CPU 的资源。

  • 适用场景:临界区执行时间短,且等待线程数少。

    原文作者:SSS不知~道
    原文地址: https://blog.csdn.net/sss18982488580/article/details/123499423
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞