• Java 多线程:并发编程的三大特性


    可见性

    所谓线程数据的 可见性 ,指的就是内存中的某个数据,假如第一个 CPU 的一个核读取到了,和其他的核读取到这个数据之间的可见性。

    每个线程会保存一份拷贝到线程本地缓存,使用 volatile ,可以保持线程之间数据可见性。

    如下示例

    1. package git.snippets.juc;
    2. import java.util.concurrent.TimeUnit;
    3. /**
    4. * 并发编程三大特性之:可见性
    5. *
    6. * @author Grey
    7. * @since 1.8
    8. */
    9. public class ThreadVisible {
    10. static volatile boolean flag = true;
    11. public static void main(String[] args) throws Exception {
    12. Thread t = new Thread(() -> {
    13. System.out.println(Thread.currentThread() + " t start");
    14. while (flag) {
    15. // 如果这里调用了System.out.println()
    16. // 会无论flag有没有加volatile,数据都会同步
    17. // 因为System.out.println()背后调用的synchronized
    18. // System.out.println();
    19. }
    20. System.out.println(Thread.currentThread() + " t end");
    21. });
    22. t.start();
    23. TimeUnit.SECONDS.sleep(3);
    24. flag = false;
    25. // volatile修饰引用变量
    26. new Thread(a::m, "t2").start();
    27. TimeUnit.SECONDS.sleep(2);
    28. a.flag = false;
    29. // 阻塞主线程,防止主线程直接执行完毕,看不到效果
    30. System.in.read();
    31. }
    32. private static volatile A a = new A();
    33. static class A {
    34. volatile boolean flag = true;
    35. void m() {
    36. System.out.println("m start");
    37. while (flag) {
    38. }
    39. System.out.println("m end");
    40. }
    41. }
    42. }

    代码说明:

    • volatile 修饰了 flag 变量,主线程改了 flag 的值,子线程可以感知到;

    • 如在上述代码的死循环中增加了 System.out.println() , 则会强制同步 flag 的值,无论 flag 本身有没有加 volatile ;

    • 如果 volatile 修饰一个引用对象,如果对象的属性(成员变量)发生了改变, volatile 不能保证其他线程可以观察到该变化。

    关于三级缓存

    如上图,内存读出的数据会在 L3,L2,L1 上都存一份。

    在从内存中读取数据的时候,根据的是程序局部性的原理,按块来读取,这样可以提高效率,充分发挥总线 CPU 针脚等一次性读取更多数据的能力。

    所以这里引入了一个缓存行的概念,目前一个缓存行多用 64个字节 来表示。

    如何来验证 CPU 读取缓存行这件事,我们可以通过一个示例来说明:

    1. package git.snippets.juc;
    2. /**
    3. * 缓存行对齐
    4. *
    5. * @author Grey
    6. * @since 1.8
    7. */
    8. public class CacheLinePadding {
    9. public static T[] arr = new T[2];
    10. static {
    11. arr[0] = new T();
    12. arr[1] = new T();
    13. }
    14. public static void main(String[] args) throws Exception {
    15. Thread t1 = new Thread(() -> {
    16. for (long i = 0; i < 1000_0000L; i++) {
    17. arr[0].x = i;
    18. }
    19. });
    20. Thread t2 = new Thread(() -> {
    21. for (long i = 0; i < 1000_0000L; i++) {
    22. arr[1].x = i;
    23. }
    24. });
    25. final long start = System.nanoTime();
    26. t1.start();
    27. t2.start();
    28. t1.join();
    29. t2.join();
    30. System.out.println((System.nanoTime() - start) / 100_0000);
    31. System.out.println("arr[0]=" + arr[0].x + " arr[1]=" + arr[1].x);
    32. }
    33. private static class Padding {
    34. public volatile long p1, p2, p3, p4, p5, p6, p7;
    35. }
    36. // T这个类extends Padding与否,会影响整个流程的执行时间,如果继承了,会减少执行时间,
    37. // 因为继承Padding后,arr[0]和arr[1]一定不在同一个缓存行里面,所以不需要同步数据,速度就更快一些了。
    38. private static class T /*extends Padding*/ {
    39. public volatile long x = 0L;
    40. }
    41. }

     

    代码说明

    以上代码, T 这个类继承 Padding 类与否,会影响整个流程的执行时间,如果继承了,会减少执行时间,因为继承 Padding 后, arr[0] 和 arr[1] 一定不在同一个缓存行里面,所以不需要同步数据,速度就更快一些了。

    Java SE 1.8 增加了一个注解 @Contended ,标注后就不会在同一缓存行, 但是这个注解仅适用于 Java SE 1.8,而且还需要增加 JVM 参数 -XX:-RestrictContended

    CPU 为每个缓存行标记四种状态(使用两位)

    M: 被修改(Modified)

    该缓存行只被缓存在该 CPU 的缓存中,并且是被修改过的( dirty ),即与主存中的数据不一致,该缓存行中的内存需要在未来的某个时间点(允许其它 CPU 读取请主存中相应内存之前)写回( write back )主存。

    当被写回主存之后,该缓存行的状态会变成独享( exclusive )状态。

    E: 独享的(Exclusive)

    该缓存行只被缓存在该 CPU 的缓存中,它是未被修改过的( clean ),与主存中数据一致。该状态可以在任何时刻当有其它 CPU 读取该内存时变成共享状态( shared )。

    同样地,当 CPU 修改该缓存行中内容时,该状态可以变成 Modified 状态。

    S: 共享的(Shared)

    该状态意味着该缓存行可能被多个 CPU 缓存,并且各个缓存中的数据与主存数据一致( clean ),当有一个 CPU 修改该缓存行中,其它 CPU 中该缓存行可以被作废(变成无效状态( Invalid ))。

    I: 无效的(Invalid)

    该缓存是无效的(可能有其它 CPU 修改了该缓存行)。

    有序性

    计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。

    为什么指令重排序可以提高性能?

    简单地说,每一个指令都会包含多个步骤,每个步骤可能使用不同的硬件。因此, 流水线技术 产生了,它的原理是:指令1还没有执行完,就可以开始执行指令2,而不用等到指令1执行结束之后再执行指令2,这样就大大提高了效率。

    但是,流水线技术最害怕 中断 ,恢复中断的代价是比较大的,所以我们要想尽办法不让流水线中断。指令重排就是减少中断的一种技术。

    我们分析一下下面这个代码的执行情况:

    1. a = b + c;
    2. d = e - f ;

    先加载b、c( 注意,既有可能先加载b,也有可能先加载c ),但是在执行 b + c 的时候,需要等待 b、c 装载结束才能继续执行,也就是增加了停顿,那么后面的指令也会依次有停顿,这降低了计算机的执行效率。

    为了减少这个停顿,我们可以先加载 e 和 f ,然后再去加载 b + c ,这样做对程序(串行)结果是没有影响的,但却减少了停顿:既然 b + c 需要停顿,那还不如去做一些有意义的事情。

    综上所述, 指令重排对于提高 CPU 处理性能十分必要。虽然由此带来了乱序的问题,但是这点牺牲是值得的。

    指令重排一般分为以下三种:

    第一种:编译器优化重排

    编译器在 不改变单线程程序语义 的前提下,可以重新安排语句的执行顺序。

    第二种:指令并行重排

    现代处理器采用了指令级并行技术来将多条指令重叠执行。如果 不存在数据依赖性 (即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。

    第三种:内存系统重排

    由于处理器使用缓存和读写缓存冲区,这使得加载( load )和存储( store )操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。

    指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题。

    乱序存在的条件是:不影响单线程的最终一致性( as - if - serial )

    验证乱序执行的程序示例

    1. package git.snippets.juc;
    2. /**
    3. * 并发编程的三大特性之:有序性
    4. *
    5. * @author Grey
    6. * @since 1.8
    7. */
    8. public class DisOrder {
    9. private static int x = 0, y = 0;
    10. private static int a = 0, b = 0;
    11. // 以下程序可能会执行比较长的时间
    12. public static void main(String[] args) throws InterruptedException {
    13. int i = 0;
    14. for (; ; ) {
    15. i++;
    16. x = 0;
    17. y = 0;
    18. a = 0;
    19. b = 0;
    20. Thread one = new Thread(() -> {
    21. // 由于线程one先启动,下面这句话让它等一等线程two. 读着可根据自己电脑的实际性能适当调整等待时间.
    22. shortWait(100000);
    23. a = 1;
    24. x = b;
    25. });
    26. Thread other = new Thread(() -> {
    27. b = 1;
    28. y = a;
    29. });
    30. one.start();
    31. other.start();
    32. one.join();
    33. other.join();
    34. String result = "第" + i + "次 (" + x + "," + y + ")";
    35. if (x == 0 && y == 0) {
    36. // 出现这个分支,说明指令出现了重排
    37. // 否则不可能 x和y同时都为0
    38. System.err.println(result);
    39. break;
    40. } else {
    41. // System.out.println(result);
    42. }
    43. }
    44. }
    45. public static void shortWait(long interval) {
    46. long start = System.nanoTime();
    47. long end;
    48. do {
    49. end = System.nanoTime();
    50. } while (start + interval >= end);
    51. }
    52. }

    代码说明:

    如上示例,如果指令不出现乱序,那么 x 和 y 不可能同时为 0,通过执行这个程序可以验证出来,在我本机测试的结果是:

    执行到第 385634 次 出现了 x 和 y 同时为 0 的情况,说明出现了乱序。

    原子性

    程序的原子性是指整个程序中的所有操作,要么全部完成,要么全部失败,不可能滞留在中间某个环节;在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所打断。

    一个示例:

    1. class T {
    2. m =9;
    3. }

     对象 T 在创建过程中,背后其实是包含了多条执行语句的,由于有 CPU 乱序执行的情况,所以极有可能会在初始化过程中生成以一个半初始化对象 t,这个 t 的 m 等于 0(还没有来得及做赋值操作)

    所以,不要在某个类的构造方法中启动一个线程,这样会导致 this 对象逸出:因为这个类的对象可能还来不及执行初始化操作,就启动了一个线程,导致了异常情况。

    volatile 一方面可以保证线程数据之间的可见性,另外一方面,也可以防止类似这样的指令重排,所以,单例模式中, DCL 方式的单例一定要加 volatile 修饰:

    1. public class Singleton6 {
    2. private volatile static Singleton6 INSTANCE;
    3. private Singleton6() {
    4. }
    5. public static Singleton6 getInstance() {
    6. if (INSTANCE == null) {
    7. synchronized (Singleton6.class) {
    8. if (INSTANCE == null) {
    9. try {
    10. Thread.sleep(1);
    11. } catch (InterruptedException e) {
    12. e.printStackTrace();
    13. }
    14. INSTANCE = new Singleton6();
    15. }
    16. }
    17. }
    18. return INSTANCE;
    19. }
    20. }

  • 相关阅读:
    进制转换
    【BOOST C++容器专题03】【06】Boost.Heap
    哈希函数2:用于哈希表的存储和扩容
    maven下载及安装
    爬虫知识--02
    公链与私链在数据隐私和吞吐量上的竞争
    4. MongoDB部署
    牛牛截图控件与利洽远程控制产品升级-支持证书自动升级
    Ngunx + Tomcat 负载均衡和动态分离
    JAVA计算机毕业设计中小学图书馆管理Mybatis+源码+数据库+lw文档+系统+调试部署
  • 原文地址:https://blog.csdn.net/java_lujj/article/details/126901774