面试

 

java 内存区域

     java内存区域包括:程序计数器、虚拟机栈、本地方法栈、方法区、堆。

     其中程序计数器、虚拟机栈、本地方法栈属于线程私有数据,方法区、堆属于线程共享数据。

程序计数器:

    程序计数器属于线程私有的区域,是一小块内存空间,是当前线程所执行的字节码行号指示器,通过改变计数器的值来选取下一个需要执行的代码,分支、循环、跳转、异常处理、线程恢复等都是操作都是通过程序计算器来完成。

虚拟机栈:

    虚拟机栈属于线程私有的数据区域,与线程同时创建,java方法的内存模型,每个java方法被执行时,都会创建一个栈帧,栈帧是java方法运行期间的基础数据结构。java虚拟机栈包含很多栈帧,栈帧用来存储局部变量表、操作数栈、动态链接、返回地址等。java方法的调用到结束对应着虚拟机栈中的入栈都出栈过程。

本地方法栈:

   本地方法栈属于线性私有的数据区域,主要与虚拟机用到的Native相关。

方法区:

   方法区属于线程数据共享区域,主要储存已经被虚拟机加载的类信息,在JDK7之后,原来位于方法区的字符串常量池被移动到堆中。

堆:

 堆式属于线程共享的内存区域,在虚拟机启动的时候创建,是java虚拟机管理的内存中最大的一块区域,主要用来存放对象实例,几乎所有的对象实例都在堆中分配。是java垃圾回收管理的主要区域,如果堆中没有内存完后实例分配,并且堆无法扩展,就会抛出OutOfMemoryError 异常。

 

JAVA内存模型

java内存模型(JMM)本身就是一个抽象的概念,并不真实存在,描述的是一组规则和规范,通过这个规范定义了程序中各个变量(实例字段、静态字段、构成数组对象的元素)的访问方式。JVM 运行程序的实体是线程,每个线程创建时JVM都会为其创建一个工作内存(栈空间),用来存储线程私有的数据,而在java内存模型中规定所有的变量都必须存储在主内存中,主内存是共享内存区域,所有的线程都可以访问。但是线程对于变量的操作只能在工作内存中,首先将变量从主内存拷贝到工作内存,在工作内存中操作完成之后,在将变量写回到主内存中。工作内存是线程私有的,不同的线程无法访问对方的工作内存。线程间的通信必须通过主内存来完成。

java内存模型和java内存区域划分是不同的概念层次,JMM与java内存区域唯一的相似点,都存在线程私有和线程共享数据区域,在JMM中主内存(堆内存)属于线程共享的,从某种程度上看,应该包括了堆和方法区,工作内存(栈空间)属于线程私有的,从某种程度上看,应该包括程序计数器、虚拟机栈、本地方法栈。

主内存(堆内存)

 主要存放的是java实例对象,所有线程创建的实例都存放在主内存中,不管实例对象是成员变量还是方法中的局部变量。同时也包括共享的类信息、常量、静态变量。由于是数据共享区域,多个线程同时对一个变量访问时,可能会引发线程安全问题。

工作内存(栈空间)

主要存放当前方法的所有局部变量信息(工作内存中存储着主内存的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的局部变量对其他线程不可见,即使两个线程执行的是同一段代码,他们也会在各自的工作内存中创建属于当前线程的局部变量。同时工作内存也存放着字节码指示器、相关的Native方法信息等。由于工作内存是线程私有的,所以不存在线程安全问题。

 根据虚拟机规范,对于一个实例对象中的成员方法而言,如果成员方法中的局部变量是基本数据类型(Boolean、short、byte、char、int、long、float、double),将直接存储在工作内存的栈帧结构中,但是如果方法中的局部变量是引用数据类型变量,那么该变量的引用会存储在工作内存的栈帧中,变量的实例对象就会存储在主内存中(堆内存,共享数据区域)。对于实例对象的成员变量,不管是基本类型还是引用类型或者是包装类型,都会被存储到堆中。static变量和类本身的相关信息将也会被存储在主内存中。

 

硬件内存架构与java内存模型

  硬件内存架构

      一般而言,计算机有多个CPU并且每个CPU存在多个核心,多核指一个CPU中继承多个完整的计算引擎(内核),这样就可以支持多任务并行了。从线程的调度来说,每个线程都会映射到各个CPU的内核中并行运行。在CPU内部有一组CPU寄存器,寄存器是CPU直接访问和处理数据用的,是一个临时存放数据的空间。一般CPU会从主内存中取出数据到寄存器,然后进行处理,但是内存处理数据的速度远远低于CPU,导致CPU处理数据的时间往往大量花费在等在内存处理数据中,于是就是寄存器和主内存之间添加了一个CPU缓存器,CPU缓存器处理数据的速度要比内存快的多。如果CPU寄存器总是操作主内存中同一地址的数据,缓存就可以把内存中的数据提取出来,保存到缓存器中,这样寄存器就可以直接在缓冲中取数据,但是如果不是同一个地址的数据,那么寄存器还必须绕过缓存,直接从主内存中取数据,这个现象就叫做缓存命中率。总而言之,一个CPU要访问主内存的时候,就先读取一部分数据到缓冲(如果缓存中存在需要的数据就直接从缓存中获取),进而读取缓存到寄存器,当CPU需要写入数据到主内存中,同样先把数据刷新到缓存,然后缓存把数据刷新到主内存。

java线程与硬件处理器

    java线程的实现是基于一对一模型,所谓的一对一模型,实际上就是通过语言级别层面程序去间接调用系统内核的线程模型。即我们在使用java线程时,java虚拟机内部是转而调用当前操作系统的内核线程来处理当前的任务,内核线程通过内核线程调度器来调度,每个内存线程可以看做是内核的一个分身,这就是操作系统可以同时处理多任务的原因。

   内核线程(Kernel-Level Theread),它是有操作系统内存支持的线程,这种线程由操作系统的内核来完成线程的切换,内核通过操作调度器进而对线程执行调度,并将线程任务映射到各个处理器上。

硬件内存架构与java内存模型之间的关系

   java多线程的执行最终都会映射到硬件处理器上进行执行,但是java内存模型和硬件内存架构并不完全一致,对于硬件内存架构来说,只有寄存器、缓存内存、主内存的概念,并没有工作内存(线程私有区域)和主内存(线程共享区域)之分,也就是说java内存模型对内存的划分不会影响硬件内存,因为java内存模型是一种抽象的概念,是一组规范,并不实际存在,不管是工作内存数据还是主内存数据,对于硬件来说都储存在计算机内存中,当然也可以存储在CPU缓存或者寄存器中。因此总体上说java内存模型和硬件内存架构是一个相互交叉的关系,是一个抽象概念划分与真实物理硬件的交叉。


 

   JMM存在的必要性

由于JVM运行程序的实体是线程,而每个线程创建时,JVM都会为创建一个内存空间(栈空间),用于存储线程的私有数据,线程与主内存(堆)变量操作,必须通过工作内存来间接处理,主要过程就是将主内存中的的数组拷贝到工作空间,在工作空间操作完成之后,再将变量写回主内存。但是如果存在两个线程同时对主内存中的同一个实例对象进行操作就可能引发线程安全问题。为了解决线程安全问题,JVM定义了一组规则,通过这组规则来决定一个线程对共享变量的写入何时对另一个线程可见,这组规则也称为java内存模型。JMM是围绕程序执行的原子性、有序性、可见性展开的。

原子性

原子性是指一个操作一旦执行时不可中断的,即使在多线程环境下,一个操作一旦执行就不会被其他线程影响。比如说对于一个静态变量int  x ,线程A对其进行赋值为1,线程B对其进行赋值为2,两个线程同时赋值,结果要么为1 要么为2,两个线程的操作没有干扰,这就是原子性操作,不可被中断的特点。

指令重排序

   计算机在执行程序的过程中,为了提高程序的性能,常常会对指令进行重排,一般分为3种:

 编译器优化重排

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

指令并行重排

如果不存在数据的依赖性(前一个执行语句的结果不会影响后一个语句),处理器可以改变语句的对应的机器码指令执行重排序

内存系统的重排

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

编译器优化重排属于编译器重排,指令并行重排和内存系统重排属于处理器重排,在多线程环境下,这些指令重排序可能导致出现内存可见性问题。

编译器重排

 两个线程同时执行,从程序的执行顺序上看,不会出现X2=2,X1=1的情况,但是如果编译器重排之后,可能会出现这种情况。

这种执行顺序下,就会出现上述所说的情况,这就说明在编译器重排优化下,两个线程中使用的变量能否保证一致性是无法确定的。

处理器指令重排

处理器指令重排是对CPU性能的优化,从指令的执行角度来说一条指令可以分为多个步骤完成:

  •  取指IF    
  • 译码和取寄存器操作数ID
  • 执行或者有效地址计算EX
  • 存储器访问MEM
  • 写回WB、

CPU在工作过程中,需要将上述指令分为多个步骤依次执行,由于每一步会使用到不同的硬件操作,比如取指用的PC寄存器和存储器,译码使用指令寄存器组,执行时会用到算数逻辑单元,写回用到寄存器组,为了提高CPU的利用率,CPU指令按照流水线技术执行的。

 从图中可以看出当指令1还未执行完成时,第2条指令便利用空闲的硬件开始执行,这样做是有好处的,如果每个步骤花费1ms,那么如果第2条指令需要等待第1条指令执行完成后再执行的话,则需要等待5ms,但如果使用流水线技术的话,指令2只需等待1ms就可以开始执行了,这样就能大大提升CPU的执行性能。虽然流水线技术可以大大提升CPU的性能,但不幸的是一旦出现流水中断,所有硬件设备将会进入一轮停顿期,当再次弥补中断点可能需要几个周期,这样性能损失也会很大。

下面通过汇编指令展示了上述代码在CPU执行的处理过程

  •  LW指令 表示 load,其中LW R1,b表示把b的值加载到寄存器R1中
  • LW R2,c 表示把c的值加载到寄存器R2中
  • ADD 指令表示加法,把R1 、R2的值相加,并存入R3寄存器中
  • SW 表示 store 即将 R3寄存器的值保持到变量a中
  • LW R4,e 表示把e的值加载到寄存器R4中
  • LW R5,f 表示把f的值加载到寄存器R5中
  • SUB 指令表示减法,把R4 、R5的值相减,并存入R6寄存器中
  • SW d,R6 表示将R6寄存器的值保持到变量d中

上述便是汇编指令的执行过程,在某些指令上存在X的标志,X代表中断的含义,也就是只要有X的地方就会导致指令流水线技术停顿,同时也会影响后续指令的执行,可能需要经过1个或几个指令周期才可能恢复正常,那为什么停顿呢?这是因为部分数据还没准备好,如执行ADD指令时,需要使用到前面指令的数据R1,R2,而此时R2的MEM操作没有完成,即未拷贝到存储器中,这样加法计算就无法进行,必须等到MEM操作完成后才能执行,也就因此而停顿了,停顿会造成CPU性能下降,因此我们应该想办法消除这些停顿,这时就需要使用到指令重排了,如下图,既然ADD指令需要等待,那我们就利用等待的时间做些别的事情,如把LW R4,e 和 LW R5,f 移动到前面执行,毕竟LW R4,e 和 LW R5,f执行并没有数据依赖关系,对他们有数据依赖关系的SUB R6,R5,R4指令在R4,R5加载完成后才执行的,没有影响,过程如下:

  正如上图所示,所有的停顿都完美消除了,指令流水线也无需中断了,这样CPU的性能也能带来很好的提升,这就是处理器指令重排的作用。

 对于单线程而已指令重排几乎不会带来任何影响,毕竟重排的前提是保证串行语义执行的一致性,但对于多线程环境而已,指令重排就可能导致严重的程序轮序执行问题,如下

class MixedOrder{
    int a = 0;
    boolean flag = false;
    public void writer(){
        a = 1;
        flag = true;
    }

    public void read(){
        if(flag){
            int i = a + 1;
        }
    }
}

  同时存在线程A和线程B对该实例对象进行操作,其中A线程调用写入方法,而B线程调用读取方法,由于指令重排等原因,可能导致程序执行顺序变为如下

 线程A                    线程B
 writer:                 read:
 1:flag = true;           1:flag = true;
 2:a = 1;                 2: a = 0 ; //误读
                          3: i = 1 ;

由于指令重排的原因,线程A的flag置为true被提前执行了,而a赋值为1的程序还未执行完,此时线程B,恰好读取flag的值为true,直接获取a的值(此时B线程并不知道a为0)并执行i赋值操作,结果i的值为1,而不是预期的2,这就是多线程环境下,指令重排导致的程序乱序执行的结果。因此,指令重排只会保证单线程中串行语义的执行的一致性,但并不会关心多线程间的语义一致性。

可见性

 当一个线程修改了某个变量,其他线程能否是否马上得知这个修改的值。对于串行程序不存在可见性的问题,因为我们在任何一个操作中修改了某个变量的值,后续的操作都可以读取到这个值,并且是修改过的值。但是在多线程环境中,就可能存在可见性问题,由于工作内存和主内存之间的同步延迟现象造成了可见性问题。

有序性

有序性是指对于单线程的执行代码,我们总是认为代码的执行是按顺序依次执行的。但对于多线程环境,则可能出现乱序现象,因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致。

JMM提供的解决方法

在理解了原子性,可见性以及有序性问题后,看看JMM是如何保证的,在Java内存模型中都提供一套解决方案供Java工程师在开发过程使用,如原子性问题,除了JVM自身提供的对基本数据类型读写操作的原子性外,对于方法级别或者代码块级别的原子性操作,可以使用synchronized关键字或者重入锁(ReentrantLock)保证程序执行的原子性,而工作内存与主内存同步延迟现象导致的可见性问题,可以使用synchronized关键字或者volatile关键字解决,它们都可以使一个线程修改后的变量立即对其他线程可见。对于指令重排导致的可见性问题和有序性问题,则可以利用volatile关键字解决,因为volatile的另外一个作用就是禁止重排序优化。在Java内存模型中,还提供了happens-before 原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据。

volatile内存语义

volatile是虚拟机提供的轻量级的同步机制,volatile的关键字的作用如下:

  • 保证被volatile修饰的变量对所有线程总数可见,即当一个线程修改了一个被volatile修饰的变量,新值总数可以被其他线程立即得知。
  • 禁止重排序

volatile的可见性问题

 关于volatile的可见性作用,我们必须意识到被volatile修饰的变量对所有线程总数立即可见的,对volatile变量的所有写操作总是能立刻反应到其他线程中,但是对于volatile变量运算操作在多线程环境并不保证安全性,如下:

public class VolatileVisibility {
    public static volatile int i =0;

    public static void increase(){
        i++;
    }
}

由于i++;操作并不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上1,分两步完成,如果第二个线程在第一个线程读取旧值和写回新值期间读取i的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全失败,因此对于increase方法必须使用synchronized修饰,以便保证线程安全,需要注意的是一旦使用synchronized修饰方法后,由于synchronized本身也具备与volatile相同的特性,即可见性,因此在这样种情况下就完全可以省去volatile修饰变量。

public class VolatileVisibility {
    public static int i =0;

    public synchronized static void increase(){
        i++;
    }
}

现在来看另外一种场景,可以使用volatile修饰变量达到线程安全的目的,如下

public class VolatileSafe {

    volatile boolean close;

    public void close(){
        close=true;
    }

    public void doWork(){
        while (!close){
            System.out.println("safe....");
        }
    }
}

由于对于boolean变量close值的修改属于原子性操作,因此可以通过使用volatile修饰变量close,使用该变量对其他线程立即可见,从而达到线程安全的目的。

那么JMM是如何实现让volatile变量对其他线程立即可见的呢?实际上,当写一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存中,当读取一个volatile变量时,JMM会把该线程对应的工作内存置为无效,那么该线程将只能从主内存中重新读取共享变量。volatile变量正是通过这种写-读方式实现对其他线程可见。

volatile的禁止重排序

volatile关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象,volatile是如何实现禁止指令重排优化的。先了解一个概念,内存屏障(Memory Barrier)

内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。下面看一个非常典型的禁止重排优化的例子。


public class DoubleCheckLock {

    private static DoubleCheckLock instance;

    private DoubleCheckLock(){}

    public static DoubleCheckLock getInstance(){

        //第一次检测
        if (instance==null){
            //同步
            synchronized (DoubleCheckLock.class){
                if (instance == null){
                    //多线程环境下可能会出现问题的地方
                    instance = new DoubleCheckLock();
                }
            }
        }
        return instance;
    }
}

上述代码一个经典的单例的双重检测的代码,这段代码在单线程环境下并没有什么问题,但如果在多线程环境下就可以出现线程安全问题。原因在于某一个线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化。因为instance = new DoubleCheckLock();可以分为以下3步完成(伪代码)

memory = allocate(); //1.分配对象内存空间
instance(memory);    //2.初始化对象
instance = memory;   //3.设置instance指向刚分配的内存地址,此时instance!=null

由于步骤1和步骤2间可能会重排序,如下

memory = allocate(); //1.分配对象内存空间
instance = memory;   //3.设置instance指向刚分配的内存地址,此时instance!=null,但是对象还没有初始化完成!
instance(memory);    //2.初始化对象

由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性。所以当一条线程访问instance不为null时,由于instance实例未必已初始化完成,也就造成了线程安全问题。那么该如何解决呢,很简单,我们使用volatile禁止instance变量被执行指令重排优化即可。

  //禁止指令重排优化
  private volatile static DoubleCheckLock instance;

volatile和synchronized 的区别

  •   volatile本质是告诉JVM当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized则是锁定当前对象,只有当前线程可以访问该变量,其他线程被阻塞直到该线程完成变量操作为止。
  • volatile 仅能使用在变量级别;synchronized则可以使用在变量、方法和类级别。
  • volatile仅能实现变量的修改可见性,不能保证原子性;synchronized则可以保证变量修改的可见性和原子性。
  • volatile标记的变量不会被编译器优化重排;synchronized标记的变量可以被编译器优化重排。
  • volatile不会造成线程阻塞;synchronized可能会造成线程阻塞。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值