Java并发编程第三讲

编程入门 行业动态 更新时间:2024-10-28 02:31:15

Java并发编程<a href=https://www.elefans.com/category/jswz/34/1751491.html style=第三讲"/>

Java并发编程第三讲

 JVM 内存结构 -和 Java 虚拟机的运行时区域有关;        

我们都知道,Java 代码是要运行在虚拟机上的,而虚拟机在执行 Java 程序的过程中会把所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途。在《Java 虚拟机规范(Java SE 8)》中描述了 JVM 运行时内存区域结构可分为以下 6 个区。

堆区(Heap堆是存储类实例和数组的,通常是内存中最大的一块。实例很好理解,比如 new Object() 就会生成一个实例;而数组也是保存在堆上面的,因为在 Java 中,数组也是对象。

虚拟机栈(Java Virtual Machine Stacks它保存局部变量和部分结果,并在方法调用和返回中起作用。

方法区(Method Area存储每个类的结构,例如运行时的常量池、字段和方法数据,以及方法和构造函数的代码,包括用于类初始化以及接口初始化的特殊方法。

本地方法栈(Native Method Stacks与虚拟机栈基本类似,区别在于虚拟机栈为虚拟机执行的 Java 方法服务,而本地方法栈则是为 Native 方法服务。

程序计数器(The PC Register是最小的一块内存区域,它的作用通常是保存当前正在执行的 JVM 指令地址。

运行时常量池(Run-Time Constant Pool):是方法区的一部分,包含多种常量,范围从编译时已知的数字到必须在运行时解析的方法和字段引用。

这里总结一下,JVM 内存结构是由 Java 虚拟机规范定义的,描述的是在 Java 程序执行过程中,由 JVM 管理的不同数据区域,各个区域有其特定的功能。官方的规范地址请点击这里查看。

JMM(Java Memory Model,Java 内存模型)

JMM 与处理器、缓存、并发、编译器有关。它解决了 CPU 多级缓存、处理器优化、指令重排等导致的结果不可预期的问题。 

JMM  是工具类和关键字的原理

之前我们使用了各种同步工具和关键字,包括 volatile、synchronized、Lock 等,其实它们的原理都涉及 JMM。正是 JMM 的参与和帮忙,才让各个同步工具和关键字能够发挥作用,帮我们开发出并发安全的程序。

        重排序

重排序后 a 的相关指令发生了变化,节省了一次 Load a 和一次 Store a。重排序通过减少执行指令,从而提高整体的运行速度,这就是重排序带来的优化和好处。 

重排序的 3 种情况 

内存的“重排序”、编译器优化、CPU 重排序 

程晓明《深入理解 Java 内存模型》 深入理解Java内存模型(一)——基础_Java_程晓明_InfoQ精选文章 


Java 中的原子性和原子操作

        什么是原子性和原子操作

        在编程中,具备原子性的操作被称为原子操作。原子操作是指一系列的操作,要么全部发生,要么全部不发生,不会出现执行一半就终止的情况。

        下面我们举一个不具备原子性的例子,比如 i++ 这一行代码在 CPU 中执行时,可能会从一行代码变为以下的 3 个指令:

  • 第一个步骤是读取

  • 第二个步骤是增加

  • 第三个步骤是保存

        Java 中的原子操作有哪些

在了解了原子操作的特性之后,让我们来看一下 Java 中有哪些操作是具备原子性的。Java 中的以下几种操作是具备原子性的,属于原子操作:

  • 除了 long 和 double 之外的基本类型(int、byte、boolean、short、char、float)的读/写操作,都天然的具备原子性;

  • 所有引用 reference 的读/写操作

  • 加了 volatile 后,所有变量的读/写操作(包含 long 和 double)。这也就意味着 long 和 double 加了 volatile 关键字之后,对它们的读写操作同样具备原子性;

  • 在 java.concurrent.Atomic 包中的一部分类的一部分方法是具备原子性的,比如 AtomicInteger 的 incrementAndGet 方法。

         long 和 double 的值需要占用 64 位的内存空间,而对于 64 位值的写入,可以分为两个 32 位的操作来进行。

       而在目前各种平台下的主流虚拟机的实现中,几乎都会把 64 位数据的读写操作作为原子操作来对待,因此我们在编写代码时一般不需要为了避免读到“半个变量”而把 long 和 double 声明为 volatile 的。

原子操作 + 原子操作 != 原子操作 

        I++、++i 


        能够保证可见性的措施

        除了 volatile 关键字可以让变量保证可见性外,synchronized、Lock、并发集合等一系列工具都可以在一定程度上保证可见性,具体保证可见性的时机和手段

        主内存和工作内存的关系

         假设 core 1 修改了变量 a 的值,并写入到了 core 1 的 L1 缓存里,但是还没来得及继续往下同步,由于 core 1 有它自己的的 L1 缓存,core 4 是无法直接读取 core 1 的 L1 缓存的值的,那么此时对于 core 4 而言,变量 a 的值就不是 core 1 修改后的最新的值,core 4 读取到的值可能是一个过期的值,从而引起多线程时可见性问题的发生。 

        JMM的抽象:主内存和工作内存 

 (1)所有的变量都存储在主内存中,同时每个线程拥有自己独立的工作内存,而工作内存中的变量的内容是主内存中该变量的拷贝;

(2)线程不能直接读 / 写主内存中的变量,但可以操作自己工作内存中的变量,然后再同步到主内存中,这样,其他线程就可以看到本次修改;

(3) 主内存是由多个线程所共享的,但线程间不共享各自的工作内存,如果线程间需要通信,则必须借助主内存中转来完成。


happens-before 关系

        Happens-before 关系是用来描述和可见性相关问题的:如果第一个操作 happens-before 第二个操作(也可以描述为,第一个操作和第二个操作之间满足 happens-before 关系),那么我们就说第一个操作对于第二个操作一定是可见的,也就是第二个操作在执行时就一定能保证看见第一个操作执行的结果。
        锁操作、synchronized和volatile的使用都有着紧密的联系都遵循Happens-Before。
        线程启动、线程join、线程中断以及工具类的Happens-Before,这些规则都会默认被当作已知条件去使用的


volatile 的作用和适用场景,以及它与 synchronized 有什么异同

        volatile 是什么 

        volatile,它是 Java 中的一个关键字,是一种同步机制。当某个变量是共享变量,且这个变量是被 volatile 修饰的,那么在修改了这个变量的值之后,再读取该变量的值时,可以保证获取到的是修改后的最新的值,而不是过期的值。 

        volatile的不适用场合

不适用:a++

public class DontVolatile implements Runnable {// 共享变量 被volatile修饰volatile int a;AtomicInteger realA = new AtomicInteger();public static void main(String[] args) throws InterruptedException {Runnable r =  new DontVolatile();Thread thread1 = new Thread(r);Thread thread2 = new Thread(r);thread1.start();thread2.start();thread1.join();thread2.join();System.out.println(((DontVolatile) r).a);System.out.println(((DontVolatile) r).realA.get());}@Overridepublic void run() {for (int i = 0; i < 1000; i++) {// 不是原子性操作a++;realA.incrementAndGet();}}
}

        适用场合1:布尔标记位

public class YesVolatile1 implements Runnable {volatile boolean done = false;AtomicInteger realA = new AtomicInteger();public static void main(String[] args) throws InterruptedException {Runnable r =  new YesVolatile1();Thread thread1 = new Thread(r);Thread thread2 = new Thread(r);thread1.start();thread2.start();thread1.join();thread2.join();System.out.println(((YesVolatile1) r).done);System.out.println(((YesVolatile1) r).realA.get());}@Overridepublic void run() {for (int i = 0; i < 1000; i++) {setDone();realA.incrementAndGet();}}private void setDone() {// 仅设值或仅取值保证了是原子性的操作done = true;}
}

        适用场合 2:作为触发器

Map configOptions;
char[] configText;
volatile boolean initialized = false;. . .// In thread AconfigOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;. . .// In thread Bwhile (!initialized) sleep();
// use configOptions

如果我们分别有操作 A 和操作 B,我们用 hb(A, B) 来表示 A happens-before B。而 Happens-before 是有可传递性质的,如果hb(A, B),且hb(B, C),那么可以推出hb(A, C)。

 线程A执行的内容遵循happens-before,也就是`configOptions` 会在initialized = true;之前完成。

线程B首先循环 while (!initialized) 一致sleep。在线程A赋值为true的第一时间,不符合条件继续下面的代码 执行configOptions这里已经能够看到A线程的赋值动作。

        volatile 的作用 

第一层的作用是保证可见性。Happens-before 关系中对于 volatile 是这样描述的:对一个 volatile 变量的写操作 happen-before 后面对该变量的读操作。 

第二层的作用就是禁止重排序。先介绍一下 as-if-serial语义:不管怎么重排序,(单线程)程序的执行结果不会改变。在满足 as-if-serial 语义的前提下,由于编译器或 CPU 的优化,代码的实际执行顺序可能与我们编写的顺序是不同的,这在单线程的情况下是没问题的,但是一旦引入多线程,这种乱序就可能会导致严重的线程安全问题。用了 volatile 关键字就可以在一定程度上禁止这种重排序。 

        volatile 和 synchronized 的关系 

相似性:比如一个共享变量如果自始至终只被各个线程赋值和读取,而没有其他操作的话,那么就可以用 volatile 来代替 synchronized 或者代替原子变量,足以保证线程安全。实际上,对 volatile 字段的每次读取或写入都类似于“半同步”——读取 volatile 与获取 synchronized 锁有相同的内存语义,而写入 volatile 与释放 synchronized 锁具有相同的语义。 

不可代替:但是在更多的情况下,volatile 是不能代替 synchronized 的,volatile 并没有提供原子性和互斥性。 

性能方面:volatile 属性的读写操作都是无锁的,正是因为无锁,所以不需要花费时间在获取锁和释放锁上,所以说它是高性能的,比 synchronized 性能更好。 


双重检查锁模式为什么必须加 volatile

单例模式指的是,保证一个类只有一个实例,并且提供一个可以全局访问的入口。 

public class Singleton {private static volatile Singleton singleton;private Singleton() {}public static Singleton getInstance() {if (singleton == null) {synchronized (Singleton.class) {if (singleton == null) {singleton = new Singleton();}}}return singleton;}
}

         由于锁机制的存在,会有一个线程先进入同步语句,并进入第二重 if 判断 ,而另外的一个线程就会在外面等待。

        不过,当第一个线程执行完 new Singleton() 语句后,就会退出 synchronized 保护的区域,这时如果没有第二重 if (singleton == null) 判断的话,那么第二个线程获取到锁就会也会创建一个实例,此时就破坏了单例,这肯定是不行的。

        而对于第一个 check 而言,如果去掉它,那么所有线程都会串行执行,效率低下,所以两个 check 都是需要保留的。

singleton = new Singleton() ,它并非是一个原子操作,事实上,在 JVM 中上述语句至少做了以下这 3 件事:

          因为存在指令重排序的优化,也就是说第2 步和第 3 步的顺序是不能保证的,最终的执行顺序,可能是 1-2-3,也有可能是 1-3-2:

         在 JDK 5 以及后续版本所使用的 JMM 中,在使用了 volatile 后,会一定程度禁止相关语句的重排序,从而避免了上述由于重排序所导致的读取到不完整对象的问题的发生

        两个线程竞争 CAS,其中一个落败

public class DebugCAS implements Runnable {private volatile int value;public synchronized int compareAndSwap(int expectedValue, int newValue) {int oldValue = value;if (oldValue == expectedValue) {value = newValue;System.out.println("线程"+Thread.currentThread().getName()+"执行成功");}return oldValue;}public static void main(String[] args) throws InterruptedException {DebugCAS r = new DebugCAS();r.value = 100;Thread t1 = new Thread(r,"Thread 1");Thread t2 = new Thread(r,"Thread 2");t1.start();t2.start();t1.join();t2.join();System.out.println(r.value);}@Overridepublic void run() {compareAndSwap(100, 150);}
}

CAS 是有很多优点的,比如可以避免加互斥锁,可以提高程序的运行效率。

CAS 有什么缺点

        ABA 问题

        决定 CAS 是否进行 swap 的判断标准是“当前的值和预期的值是否一致”,如果一致,就认为在此期间!!这个数值没有发生过变动!!,这在大多数情况下是没有问题的。 

        假设第一个线程拿到的初始值是 100,然后进行计算,在计算的过程中,有第二个线程把初始值改为了 200,然后紧接着又有第三个线程把 200 改回了 100。等到第一个线程计算完毕去执行 CAS 的时候,它会比较当前的值是不是等于最开始拿到的初始值 100,此时会发现确实是等于 100,所以线程一就认为在此期间值没有被修改过,就理所当然的把这个 100 改成刚刚计算出来的新值,但实际上,在此过程中已经有其他线程把这个值修改过了,这样就会发生 ABA 问题

        在 atomic 包中提供了 AtomicStampedReference 这个类,它是专门用来解决 ABA 问题的,解决思路正是利用版本号,AtomicStampedReference 会维护一种类似 <Object,int> 的数据结构,其中的 int 就是用于计数的,也就是版本号,它可以对这个对象和 int 版本号同时进行原子更新,从而也就解决了 ABA 问题。因为我们去判断它是否被修改过,不再是以值是否发生变化为标准,而是以版本号是否变化为标准,即使值一样,它们的版本号也是不同的。 

        自旋时间过长 

由于单次 CAS 不一定能执行成功,所以 CAS 往往是配合着循环来实现的,有的时候甚至是死循环,不停地进行重试,直到线程竞争不激烈的时候,才能修改成功。 

        范围不能灵活控制 

我们不能针对多个共享变量同时进行 CAS 操作,因为这多个变量之间是独立的,简单的把原子操作组合到一起,并不具备原子性。因此如果我们想对多个对象同时进行 CAS 操作并想保证线程安全的话,是比较困难的。 

有一个解决方案,那就是利用一个新的类,来整合刚才这一组共享变量,这个新的类中的多个成员变量就是刚才的那多个共享变量,然后再利用 atomic 包中的 AtomicReference 来把这个新对象整体进行 CAS 操作,这样就可以保证线程安全。 

更多推荐

Java并发编程第三讲

本文发布于:2024-03-06 18:24:58,感谢您对本站的认可!
本文链接:https://www.elefans.com/category/jswz/34/1716045.html
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。
本文标签:第三讲   Java

发布评论

评论列表 (有 0 条评论)
草根站长

>www.elefans.com

编程频道|电子爱好者 - 技术资讯及电子产品介绍!