并发编程一:深入理解JMM和并发三大特性(上)

深入理解JMM和并发三大特性(上)

前言

JMM属于整个Java并发编程中最难的部分也是最重要的部分(JAVA多线程通信模型——共享 内存模型),涉及的理论知识比较多,我会从三个维度去分析: JAVA层面、JVM层面 、硬件层面。
在了解JMM和并发编程之前先来看看什么是并发,多线程有什么作用,并发编程出现bug的根本原因是什么。

并发和并行

并发和并行目标都是最大化CPU的使用率
并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。所以无论从微观还是从宏观来看,二者都是一起执行的
在这里插入图片描述
并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。
在这里插入图片描述
多线程的作用
对于多线程的作用有这三个:同步、互斥、分工。
同步:指的是A线程的结果需要依赖B线程的结果。比如说用户访问tomcat开启一个tomcat的线程。而tomcat线程会去访问应用程序线程,应用程序线程又会调用jdbc的线程访问数据。拿到一系列结果后放回给tomcat 在放回给用户。这就是线程之间的同步协作。
互斥:指的是A线程在使用这个资源,其他线程无法使用,必须等到A线程释放后才能访问该资源。比如在数据库中对某个数据加了写锁,在一个线程进行写操作的时候另一个线程无法访问到该数据。
分工:每个线程分配不同的任务,最后结果汇总起来。比如说在计算很大数据是,开启多个线程,每个线程计算一部分,最后把所有线程计算的结果汇总

并发编程出现bug的根本原因
根本原因在于:可见性、原子性和有序性问题。这也是并发的三大特性

并发编程三特性

可见性
当一个线程修改了共享变量的值,其他线程能够看到修改的值。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介 的方法来实现可见性的。 如何保证可见性:

  • 通过 volatile 关键字保证可见性。
  • 通过 内存屏障保证可见性。
  • 通过 synchronized 关键字保证可见性。
  • 通过 Lock锁机制保证可见性。
  • 通过 final 关键字保证可见性

有序性
即程序执行的顺序按照代码的先后顺序执行。JVM 存在指令重排,所以存在有序性问题。 如何保证有序性:

  • 通过 volatile 关键字保证有序性。
  • 通过 内存屏障保证有序性。
  • 通过 synchronized关键字保证有序性。 通过 Lock锁机制保证有序性。

原子性
一个或多个操作,要么全部执行且在执行过程中不被任何因素打断,要么全部不执行。在 Java 中,对基本数据类型的变量的读取和赋值操作是原子性操作(64位处理器)。不采取任何的原子性保障措施的自增操作并不是原子性的。如何保证原子性:

  • 通过 synchronized 关键字保证原子性。
  • 通过 Lock锁机制保证原子性。
  • 通过 CAS保证原子性。

下面深入分析这三大特性,在分析三大特性之前还需要了解JMM的内存模型

JMM内存模型

JMM定义:
Java虚拟机规范中定义了Java内存模型(Java Memory Model,JMM),用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工作的:
规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。JMM 描述的是一种抽象的概念,一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性、有序性、可见性展开的。
在这里插入图片描述
JMM与硬件内存架构的关系
Java内存模型与硬件内存架构之间存在差异。硬件内存架构没有区分线程栈和堆。对于硬件,所有的线程栈和堆都分布在主内存中。部分线程栈和堆可能有时候会出现在CPU缓存中和CPU内部的寄存器中。如下图所示,Java内存模型和计算机硬件内存架构是一个交叉关系:
在这里插入图片描述
内存交互操作
关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、 如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成:

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
  • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放 后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存 中,以便随后的load动作使用
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放 入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主 内存中,以便随后的write的操作。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值 传送到主内存的变量中。

在这里插入图片描述
Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

  • 如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作;如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。 但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
  • 不允许read和load、store和write操作之一单独出现
  • 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化 (load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过 了assign和load操作。
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用 这个变量前需要重新执行load或assign操作初始化变量的值
  • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许 去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和 write操作)。

可见性深入分析

下面通过一个例子对可见性进行深入分析

package demo;

public class visibilityDemo {
	private  boolean flag = true;
	private int count = 0;

	public void refresh() {
		flag = false;
		System.out.println(Thread.currentThread().getName() + "修改flag:"+flag);
	}

	public void load() {
		System.out.println(Thread.currentThread().getName() + "开始执行.....");
		while (flag) {
			//TODO  业务逻辑
			count++;
		}
		System.out.println(Thread.currentThread().getName() + "跳出循环: count=" + count);
	}

	public static void main(String[] args) throws InterruptedException {
		visibilityDemo test = new visibilityDemo();

		// 线程threadA模拟数据加载场景
		Thread threadA = new Thread(() -> test.load(), "threadA");
		threadA.start();

		// 让threadA执行一会儿
		Thread.sleep(1000);
		// 线程threadB通过flag控制threadA的执行时间
		Thread threadB = new Thread(() -> test.refresh(), "threadB");
		threadB.start();
	}
}

解释一下,load方法是一个while循环,依赖于flag这个变量,refresh方法修改flag变量值。执行main方法,首先flag是true,因此执行load方法是一个死循环,refresh修改flag为false,能不能结束这个循环呢?看一下执行结果
在这里插入图片描述
并没有跳出循环。为什么没有跳出循环呢?这里涉及到并发编程的可见性。就是说B线程修改了flag的值,但是这个值对于A线程来说不可见。那为什么不可见,我们来了解一下线程的内存数据是如何调用的。
在这里插入图片描述

在看下这个图,假设线程1在执行while方法,首先会先从自己的工作内存当中加载flag,但是第一次加载的时候工作内存中没有flag这个值,那么就会从主内存中通过read、load获取到flag=true到工作内存中,线程1通过use来使用这个flag。此时线程1在执行死循环。
假设此时线程三修改这个flag的值,同样第一次线程三的工作内存中没有flag,就会从主内存中通过reda、load加载到工作内存中,通过assign对flag赋值为false,然后通过store、write写入到主内存中。此时主内存中的flag=false。
但是线程一中的工作内存有这个flag的值为true,所以不会重新从主内存获取flag的值。因此线程三对flag的修改对于线程一来说是不可见的。
看了上述的过程就会产生一系列的问题,比如

  • 说什么时候可见,就是说什么时候会重新从主内存获取flag呢?
    简单的来说当线程一的工作内存中的flag没有了,自然会重新从主内存中获取,那么此时flag就是false了。当然还有其他的手段,下面会讲到。
  • 那什么时候工作内存的falg会没有呢?
  • 什么时候会刷新主内存的值,是一修改就刷新还是线程结束后在刷新?
    在解释这些问题之前,先来看下如何修改这些代码能够跳出循环,再根据这些代码一一解释。

方案1:在flag 加上 volatile 即 private boolean flag = true; 改为 private volatile boolean flag = true;
结果:跳出循环
在这里插入图片描述
方案2:在 count 加上 volatile
在这里插入图片描述
结果:跳出循环
在这里插入图片描述
方案3:使用内存屏障
在这里插入图片描述
结果:跳出循环
在这里插入图片描述
方案4:使用Thread.yield()
在这里插入图片描述
结果:跳出循环
在这里插入图片描述
方案5:使用System.out.println()
在这里插入图片描述
结果:跳出循环
在这里插入图片描述
方案6 使用int count 类型换成Integer
在这里插入图片描述
方案7:使用凭证
在这里插入图片描述

等等还有很多方案,以上方案都是能够满足可见性的。
下面具体一个个方案来解释:首先要从java层面是很难去解释,要从JMM角度去解释,JMM角度上面有解释过了也就是如何让工作内存重新从主内存加载flag这个值。在解析这些方案之前先回答下上面提出的几个问题

什么时候会刷新主内存的值,是一修改就刷新还是线程结束后在刷新?
可以这么理解,如果不做任何额外操作的话,在线程中修改变量的话是不会马上刷新主内存的值的,当这个线程对这个变量不使用的情况下,那么这个变量就会回收,在回收前就会刷新主内存的值。

什么时候工作内存的falg会没有呢?
线程中的缓存是有时间限制的,内存空间本身就比较小,如果数据多了不够放了自然会淘汰之前的缓存。或者在while里面执行了1毫秒,才用到这个falg,那么这个falg就会被淘汰。就是说缓存淘汰时间不到1毫秒。或者一些其他操作也会导致缓存淘汰。这涉及到缓存淘汰的机制和硬件层面有关,这就超纲了,知道有这么回事就好。

方案解析
方案一解析:为什么加上volatile 能够跳出循环
首先volatile是java的关键字,要找原因要去看jvm的源码。
volatile在hotspot的实现
在这里插入图片描述
这串代码意思是判断是不是volatile修饰的,如果是执行OrderAccess::streload()OrderAccess::storeload()这个意思是加上内存屏障。这个内存屏障是jvm层面,不要跟其他搞混了。jvm的内存屏障主要有四种:storestore loadload storeload loadstore
OrderAccess::streload()在linux系统x86中的实现

inline void OrderAccess::storeload()  { fence(); }
inline void OrderAccess::fence() {
  if (os::is_MP()) {
    // always use locked addl since mfence is sometimes expensive
#ifdef AMD64
    __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
    __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
  }

实际上OrderAccess::streload()调用了fence()方法。首先这个fence()方法会先判断处理器是不是多核的,如果是单核的不会出现可见性问题。如果是多核的 会加上 ("lock; addl $0,0(%%rsp)这一串代码,这是汇编层面的指令。可以叫做lock前缀指令也可以理解为内存屏障的意思。汇编指令就和硬件有关了。来看下lock前缀指令的作用

  1. 确保后续指令执行的原子性。在Pentium及之前的处理器中,带有lock前缀的指令在执 行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存,很显然,这个开销很 大。在新的处理器中,Intel使用缓存锁定来保证指令执行的原子性,缓存锁定将大大降低 lock前缀指令的执行开销。
  2. LOCK前缀指令具有类似于内存屏障的功能,禁止该指令与前面和后面的读写指令重排序。
  3. LOCK前缀指令会等待它之前所有的指令完成、并且所有缓冲的写操作写回内存(也就是将store buffer中的内容写入内存)之后才开始执行,并且根据缓存一致性协议,刷新 store buffer的操作会导致其他cache中的副本失效。

概念有点多,来总结一下。也即是volatilejvm的实现中会调用OrderAccess::streload()OrderAccess::streload()调用fence()fence()存在lock前缀指令。这lock前缀指令把B线程修改后的falg值立即写入到主内存,并且让其他副本也即是A线程中的falg缓存失效,那么A线程就会从主线程获取flag的值。

方案二和方案一相同。反正volatile就是让B线程flag的assign操作完成立即store、write写入主内存。并且让A线程的flag失效。

方案三使用内存屏障和LOCK前缀指令效果一样。内存屏障这里先了解下后续会深入理解

方案四解析:为什么使用Thread.yield() 能够跳出循环
因为Thread.yield() 会释放时间片,释放时间片就会有上下文切换,上下文切换是这样的,A线程在执行while循环flag为false,在切换上下文之前会先保存这个时间片的产生的值,就是同步回主内存。在下一次的轮到A线程执行的时候会从主内存总获取上下文的值继续执行。由于JMM规定了:不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。所以在A线程上下文切换时flag不会同步回主内存。并且上下文切换大概需要几毫秒的时间,所以A线程中的flag早就被淘汰了,下一次会直接读取主内存的flag的值,由于refresh方法修改了flag的值并且刷新到主内存当中,所以主内存falg在refresh方法结束后就已经是false了。这就是Thread.yield()能过跳出循环的原因

方案五解析:为什么使用使用System.out.println()能够跳出循环
看下println()的源码
在这里插入图片描述
synchronized底层也是调了fence()

方案六:使用Integer为什么可以跳出循环
在这里插入图片描述
在这里插入图片描述

可以看到Integer的value值被 final 修饰了。jvm对final定义是不可变的,保证final修饰的变量具有可见性

方案七使用了凭证和方案五类似,底层调用了fence()

以上就是可见性的分析。

总结

Java中可见性如何保证? 方式归类有两种:

  1. jvm层面 storeLoad内存屏障 ===> x86 lock替代了mfence
  2. 上下文切换 Thread.yield();
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: 《并发编程(第二版)》是Java并发编程领域的经典著作,该书由Doug Lea 和其他几位知名的Java并发编程专家合作撰写。该书对Java并发编程的原理、模型、技术和最佳实践进行了全面阐述,力求帮助读者深入理解Java并发编程领域的各种问题。 该书首先介绍了Java并发编程的背景、Java语言的特性和多线程编程的基本概念。接着,书中详细讲解了Java并发编程中的线程、锁、原子变量、线程池、并发容器等常用技术,通过例子和源码的形式进行了深入的解析,并提供了实现并发编程的最佳实践。此外,该书还介绍了Fork/Join框架、并发编程工具和Java 8 Lambda表达式在并发编程中的应用等高级技术。 总的来说,《并发编程(第二版)》是一本Java并发编程领域的权威著作,对于想深入学习Java并发编程的开发者来说是一本必备之书。该书内容全面、深入浅出,不仅介绍了Java并发编程的原理和技术,还提供了实现并发编程的最佳实践,十分实用。对于Java开发者来说,《并发编程(第二版)》是不可错过的一本经典著作。 ### 回答2: 《Java并发编程实战》第二版是一本权威的经典著作,旨在帮助Java程序员掌握并发编程的核心技术,解决多线程程序中可能出现的各种并发问题。 本书共分为4个部分,分别介绍了Java并发编程的基础、高级特性并发工具和大型并发架构开发案例。其中,基础部分主要介绍了Java中的内存模型、线程安全性、对象的共享和同步等概念;高级特性部分则深入讲解了线程池、任务框架、锁、原子变量、JMM和AQS等高级特性并发工具部分则详细介绍了Java中常用的并发工具类,如ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue等;最后,在大型并发架构开发案例部分,则结合实际项目开发经验,为读者呈现了一些实际应用案例。 本书全面深入地介绍了Java并发编程的相关技术,对于Java程序员来说是一本十分实用的工具书。无论初学者还是资深开发者,都可以从中学到许多与实际开发相关的知识和技巧。同时,本书的例子都非常实用,给人以深刻的印象。 ### 回答3: 《并发编程(第二版)》是由Java并发领域的权威人士Brian Goetz和其他Java并发专家共同编写的。本书以深入浅出的方式,系统、全面地介绍了Java并发编程中的重要概念、原理、应用技巧等内容。 本书分为四个部分。第一部分介绍并发编程的基础概念,如线程、锁、安全性等。第二部分讲解并发编程的高级特性,如非阻塞同步、原子变量、并行集合、任务执行框架等。第三部分介绍了Java并发编程中的一些优化技巧,如锁优化、线程池优化等。第四部分则着重介绍了Java并发编程中的一些挑战,如死锁、饥饿等问题,并说明了如何解决这些问题。 本书的亮点在于深入的探讨了Java并发编程中的核心问题,并提供了很多实践案例和建议,使读者能够深入理解并发编程的重要概念和技术,并将其应用于实际项目中。此外,本书还提供了大量的代码示例和图表,便于读者更好地理解和掌握Java并发编程中的各种技术和方法。 总的来说,《并发编程(第二版)》是一本值得Java并发编程开发人员和学习者阅读的优秀著作,它将帮助读者更加深入理解Java并发编程,从而提升项目开发的效率和质量。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值