由浅入深理解Java内存模型,以及Java多线程

 2019-12-10 16:10  阅读(737)
文章分类:Java 并发编程

Java多线程这个问题一直是困扰很多人的,尤其是其中的线程同步的方法,以及各种锁机制,关键字等的用法,是面试的常考点,所以今天将这几天理解的线程的知识总结一下。

之前理解Java多线程太过于浅层了,只是简单的记一下怎么创建多线程,以及多线程的方法,和Object中关于多线程的同步问题的方法。但这些东西对于多线程来说还永远不够,接下来我们从Java的内存模型开始说起。

首先看图:

20191210001620\_1.png

这是Java内存模型图(图片来自于百度),值得注意的是Java内存模型和我们正常所说的JVM的内存区域还不是一个东西,一般来说JVM中的内存区域分为程序计数器、虚拟机栈、方法区、本地方法区、Java堆,而我们所说的Java内存模型和内存区域不是一个层次上的东西。

我的理解就是类似于MVC模型,我们一般说Javaweb开发都要遵循MVC模型这样子更清晰易维护更容易开发,而有时我们就将servlet当成C层,把jsp当成V层,把Service和Dao当成M层,但其实这俩着没有一定的必然联系,比如我们用SSH开发的话,C层就会变为Action,用SSM开发的话C层又会变为Controller,所以说MVC模型是一个理论上的模型,是一个抽象的东西,而servlet、jsp、service这些都是用实类来演化这个模型的,同样的思想Java内存模型和JVM内存区域也是这个道理。按照我的理解,其实Java中很多比较抽象模糊的概念都是可以这样来理解的。(笔者自己观点,仅供参考,如果有错误,可以在留言区提出来)

Java内存模型如上图所示,其实就是为每一个线程创建一个工作空间,然后这些线程又同时共享一个主内存。线程的工作内存中保存了该线程使用到的变量到主内存副本拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成。

而对于内存区域和内存模型的对比理解可以这样看:内存区域中的程序计数器、虚拟机栈、本地方法区都是线程私有,所以都应该存在于线程的工作空间中,而方法区和Java堆为所有线程共有,所以可以看成是主内存中的。

总的来说Java内存模型本质上就是用来制定一个规则,即每个线程都是怎么来访问变量(包括实例字段,静态字段和构成数组对象的元素)的。

那么还有一个问题(是我在学习时最疑惑的一个),就是Java中那么多知识,为什么内存模型就只涉及到了线程呢?(语言有点混乱,不过看答案应该能知道我在说什么)

其实原因就是:由于JVM运行程序的实体是线程,也是说我们写的每一段程序Java都会给它分线程来看,比如我们一般写就只有一个主线程,那么就可以看成是我们的系统在JVM中运行时就只有一个人在干活,有俩个线程之后级就可以看成是有俩个人在干活,至于里面的进行的文件的上传下载啊,使用了集合工具啊这些详细的代码都依赖于一个线程,也就是这里的一个人。所以在JVM看来他的眼里只有一个个线程在运行,所以内存模型也是以线程为单位划分。(这完全是个人的理解,因为我理解不了太抽象的东西,所以这些看起来很奇怪的东西,又想把他弄清楚,只能有自己的想象力了,可以参考,但不能信)

这是Java的内存模型,那么我们知道了线程对共享变量的操作的机理之后,再来看看我们用Thread类和Runnable类创建的线程,究竟是怎么影响CPU的运行的呢?

在Window系统和Linux系统上,Java线程的实现是基于一对一的线程模型,所谓的一对一模型,实际上就是通过语言级别层面程序去间接调用系统内核的线程模型,即我们在使用Java线程时,Java虚拟机内部是转而调用当前操作系统的内核线程来完成当前任务。这里需要了解一个术语,内核线程(Kernel-Level Thread,KLT),它是由操作系统内核(Kernel)支持的线程,这种线程是由操作系统内核来完成线程切换,内核通过操作调度器进而对线程执行调度,并将线程的任务映射到各个处理器上。

这段话是另一位大神博客中的(会把地址贴在后面的),其实这就是Java中我们写的线程怎么去影响真正的cpu的,其实就是当我们写了一个线程之后,JVM运行字节码文件时会检测到,然后就会调用内核线程,进而完成线程调度。

看了上面的内容之后就会有相应的问题出现,比如说

20191210001620\_2.png

我们会在想,当我用线程A来获取主内存中的共享变量中的一个副本之后,在更改它的值,然后再用线程B来获取同一个共享变量,结果会怎么样呢,B的值到底会是什么呢?

其实B的值和A中改变的值有没有传入主内存有关,这也是线程安全问题的根本原因,不过既然提到这个了,就说一下一个名词:线程同步:我刚开始学的时候真的不理解为什么要就同步这个词了,所以每次一遇到这个知识时就感觉好难理解,但其实懂了1原理之后就很好理解了,原理和工作内存与主内存有关,我们在将工作内存中修改后的内容刷新到主内存中是,当其他线程再次访问主内存中同样的数据时,数据就会变得一样了,这样不就是同步了麽,大家一起起步都一样。

其实刚刚说的JAVA内存模型也会针对于这个而提出的,看看大神怎么说的:为了解决类似上述的问题,JVM定义了一组规则,通过这组规则来决定一个线程对共享变量的写入何时对另一个线程可见,这组规则也称为Java内存模型(即JMM)。

那么再来看看这个问题会引起哪些相关的问题呢?

接下来就到了程序执行的原子性、有序性、可见性。

这三个看似是一个特性,但每个都对应着一个问题。

原子性:原子性是指在一个操作中就是cpu不可以在中途暂停然后再调度,既不被中断操作,要不执行完成,要不就不执行。(类似于事务的原子性)

原子性也是解决上面的问题的一个方案,具体的体现是:非原子操作都会存在线程安全问题,需要我们使用同步技术(sychronized)来让它变成一个原子操作。一个操作是原子操作,那么我们称它具有原子性。

而要是不适用重型锁的原子操作有,a=0这类的,而比如说a++; 这个操作实际是a = a + 1;是可分割的,所以他不是一个原子操作。

第二个为可见性:当一个线程修改了线程共享变量的值,其它线程能够立即得知这个修改。

而要理解可见性,我们先来看下指令重排是什么?

  • 编译器优化的重排

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

  • 指令并行的重排

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

  • 内存系统的重排

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

(大神原话)

首先我们一般写程序是,又是后不太在意程序的性能之类的,所以有些代码的质量就不会太好。而当源文件在被编译成字节码文件时,编译器就会帮我们优化一下代码,也就是会重新排列一下代码,这就会在成指令重排,而这仅仅只是一种指令重排的现象,还有其他俩种分别是:指令并行重排和内存系统重排(其内不原理我现在有点模糊,我会在后面贴出大神的文章,里面有详细的解释

大神博文:https://blog.csdn.net/javazejian/article/details/72772461

看完这些再来看可见性,现在来总结造成不可见性的原因了,就是工作内存和主内存的同步延迟和指令重排俩种情况造成的。

那么如何结果这个问题呢?

其实有三个关键字就可以解决分别为:

volatile:使用此关键字修饰变量时,变量就会当一个线程修改了此共享变量的值,新值总数可以被其他线程立即得知,并且它还可以禁止指令重排。

synchronized:这个不用多说了,效果大家都知道。

final:这个关键字是我之前没想到的,好像还有更多的用法之后会弄清楚的。

这是可见性。

有序性:本线程内观察,所有操作都是有序的,如果在一个线程中观察另一个线程,所有操作都是无序的。

那在程序中我们如何保持有序性呢?

其实使用volatile和synchronized这俩个关键字就完全可以,但是可以这样想一下,在单线程的程序中还要使用volatile和synchronized来保持的话程序的性能就会大大降低,而我们正常写代码时也没有在程序中如此频繁的用到这俩个关键字的。所以这是就有另一个方法来解决这个盲区了。

这个方法就是“先行发生原则”(Happen-Before),这个原则在我看来也不是只针对于并发问题的。内容如下:

  • 程序顺序原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
  • 锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
  • volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
  • 线程启动规则 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见
  • 传递性 A先于B ,B先于C 那么A必然先于C
  • 线程终止规则 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。
  • 线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
  • 对象终结规则 对象的构造函数执行,结束先于finalize()方法

有了这个原则一切就回到了我们熟悉的范围了。

这些就是Java的内存模型,以及Java线程的深入理解部分,可以看到Java多线程出现线程不安全的真正原因,以及理解线程同步的真正解决方法是怎么解决的,还有volatile和synchronized这俩个关键字的作用,等等。接下来就是我们学的线程的生命周期和线程的方法作用总结,线程池概念以及实现,线程的几个关键字的深入理解,以及锁机制的理解,这些内容了下一篇博客应该会实现一边,这一篇主要就是把自己看的几篇博客的理解记录下来。

参考博客:https://blog.csdn.net/javazejian/article/details/72772461

https://blog.csdn.net/jaryle/article/details/51442192

https://blog.csdn.net/j2370530/article/details/55057137

点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> 由浅入深理解Java内存模型,以及Java多线程

相关推荐