并发编程之Java内存模型(JMM)

备注:最近在研究内存模型,在网上看了10多篇这方面的文章,感觉每一篇都有一些细致的地方,参考着前人的文章整理一下,《Java内存模型详解》

一,关于计算机内存模型

CPU和缓存一致性

1.CPU 的运算速度是很快的,但是内存的速度是有限的,这就会导致一个问题:CPU运算完以后会一直在等待着内存,导致效率很低,所以就产生了缓存的概念,CPU运算的时候去缓存里面查询数据,等到CPU操作完缓存,再将数据写入到内存。慢慢的为了提升效率,就产生了一级缓存,二级缓存和三级缓存。
2.此时的流程是:CPU 一级缓存 二级缓存 三级缓存 内存。
在这里插入图片描述
3.但是考虑到一个问题,当我们有多核 CPU,多线程操作的时候,每个核心都至少有一个一级缓存。多个线程访问进程中的某个共享内存,且这多个线程分别在不同的核心上执行,则每个核心都会在各自的 CACHE 中保留一份共享内存的缓冲。由于多核是可以并行的,可能会出现多个线程同时写各自的缓存的情况,而各自的cache之间的数据就有可能不同。
在CPU和主存之间增加缓存,在多线程场景下就可能存在缓存一致性问题,也就是说,在多核CPU中,每个核的自己的缓存中,关于同一个数据的缓存内容可能不一致。

处理器优化和指令重排

除了CPU和主存之间增加缓存,在多线程场景下会存在缓存一致性问题。除了这种情况,还有一种硬件问题也比较重要。那就是为了使处理器内部的运算单元能够尽量的被充分利用,处理器可能会对输入的代码进行乱序执行处理,这就是处理器优化
除了现在很多流行的处理器会对代码进行优化乱序处理,很多编程语言的编译器也会有类似的优化,比如JAVA虚拟机的及时编译器(JIT)也会对指令重排。Mysql的sql优化器。
如果任由处理器优化和编译器对指令进行重排,就会导致各种问题。

并发编程问题

原子性是指在一个操作中就是cpu不可以在中途暂停然后再调度,既不被中断操作,要不执行完成,要不就不执行。

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

有序性程序执行的顺序按照代码的先后顺序执行。
可见性就对应着缓存一致性,原子性对应着处理器优化,有序性对应着指令重排。

二,内存模型

为了保证并发编程的原子性,可见性以及有序性,就有了一个概念-内存模型
为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。他解决了CPU多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的一致性、原子性和有序性。
内存模型解决并发问题主要采取两种方式:限制处理器优化使用内存屏障

内存屏障

处理器提供了两个内存屏障指令(Memory Barrier)用于解决上述两个问题:

写内存屏障(Store Memory Barrier):在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。强制写入主内存,这种显示调用,CPU就不会因为性能考虑而去对指令重排。

读内存屏障(Load Memory Barrier):在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从主内存加载数据。强制读取主内存内容,让CPU缓存与主内存保持一致,避免了缓存导致的一致性问题。

三,java内存模型

1.概念

计算机内存模型,这是解决多线程场景下并发问题的一个重要规范。那么具体的实现是如何的呢,不同的编程语言,在实现上可能有所不同。这就类似于jdk定义了一套jdbc的接口,各个数据库的厂商来进行具体实现。

Java程序是需要运行在Java虚拟机上面的,Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。

Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。

而JMM就作用于工作内存和主存之间数据同步过程。他规定了如何做数据同步以及什么时候做数据同步。

JMM是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。

2.实现

volatile、synchronized、final、concurrent包等,其实这些就是Java内存模型封装了底层的实现后提供给程序员使用的一些关键字。

在开发多线程的代码的时候,我们可以直接使用synchronized等关键字来控制并发,从来就不需要关心底层的编译器优化、缓存一致性等问题。所以,Java内存模型,除了定义了一套规范,还提供了一系列原语,封装了底层实现后,供开发者直接使用。

1.原子性
在Java中,为了保证原子性,提供了两个高级的字节码指令monitorenter和monitorexit。这两个字节码,在Java中对应的关键字就是synchronized。因此,在Java中可以使用synchronized来保证方法和代码块内的操作是原子性的。
2.可见性
Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。

Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。
3.有序性
在Java中,可以使用synchronized和volatile来保证多线程之间操作的有序性。实现方式有所区别:

volatile关键字会禁止指令重排。synchronized关键字保证同一时刻只允许一条线程操作。

3.Volatile

用volatile关键字修饰的变量具有可见性。volatile修饰的变量不允许线程内部缓存和重排序,即直接修改内存,所以对其他线程是可见的,但是volatile只能让被他修饰的内容具有可见性,不能保证他具有原子性。

volatile int a=1;
a++;

a具有可见性,但是a++并不具有原子性,也就是a++这个操作仍然存在线程安全问题。

Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,volatile 是因为其本身包含“禁止指令重排序”的语义,synchronized 是由“一个变量在同一个时刻只允许一条线程对其进行 lock 操作”这条规则获得的,此规则决定了持有同一个对象锁的两个同步块只能串行执行。

/**
 * @author yhd
 * @createtime 2020/9/4 12:36
 */
public class DemoA {
    private static boolean flag;
    private static int num;
    private static class Yhd extends Thread{
        @Override
        public void run() {
            while(!flag)
                Thread.yield();
            System.out.println(num);
        }
    }
    public static void main(String[] args) {

        new Yhd().start();
        num=10;
        flag=true;
    }
}

这段代码,在多线程的情况下就会出现问题:
1.死循环,因为读线程可能永远看不到flag的值。
2.输出0,读线程可能看到了flag的值,还没看num线程就结束了,就会输出0.
第二种情况,这就是重排序问题了,只要在某个线程中无法检测到重排序情况(即使在其他线程中可以明显地看到该线程中的重排序),那么就无法确保线程中的操作将按照程序中指定的顺序来执行。当主线程首先写入num,然后在没有同步的情况下写入flag,那么读线程看到的顺序可能与写入的顺序完全相反。
在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,无法得到正确的结论。
问题:这是java一个失败的设计么?其实不是,它可以让jvm充分利用多核处理器的性能。在缺少同步的情况下,Java内存模型允许编译器对操作顺序进行重排序,并将数值缓存在寄存器中。此外,它还允许CPU对操作顺序进行重排序,并将数值缓存在处理器特定的缓存中。
原理
Java的volatile是一种比synchronized相对来讲更加轻量级的锁,它是用来确保变量的修改可以通知到其他线程。当你声明一个volatile类型的变量,编译器和运行时候都会知道这个变量是多线程共享的,不会将该变量上的操作与其他内存操作一起重排序。它不会被三级缓存所缓存,因此在读取的时候都是去主存来读取。
同时,volatile变量的操作过程并没有涉及得到加锁,所以就不会向synchronized一样让多个线程一起串行化,不会有线程的阻塞,相对来讲更加轻量级。

当你操作一个非volatile变量的时候,基本都是操作的三级缓存中的,操作完了三级缓存和内存在进行同步。这样就会导致,万一一个变量出现在多个缓存,并且都修改了,就会脏数据了。而加了volatile关键字,每次JVM一识别到他的时候就会直接去主存拿。
总结
基本上volatile相对sync更加轻量级,并且他会禁止指令重排,并且保证每次修改这个值以后会直接同步到主存。加了volatile以后读性能与普通变量基本没差别,但是写的话会慢一些,因为它涉及到使用内存屏障来禁止指令重排的问题。

4.synchronized

synchronized锁的一定是对象。
这个对象包括:
this
临界资源对象
Class对象
synchronized的优点
synchronized除了保证了原子性以外,还保证了可见性。因为synchronized不论你是同步方法还是同步代码块,都需要把主内存的数据先拷贝到工作内存中。同步代码块结束后,会把工作内存的数据更新到主内存,这样就保证了主内存的数据一定是最新的。更重要的是禁止了乱序重排以及值对存储器的写入,这样也是保证了可见性。
synchronized的缺点
1.等待获得锁的线程无法控制,只能死等。
2.无法保证公平性,因此会有线程插队的现象。
同步方法
同步方法锁定的是当前对象,多线程通过同一个对象调用当前的同步的方法的时候,需要同步执行,也就是只有拿到锁的线程才会执行,其他线程会被阻塞。
同步代码块
同步方法锁存在问题的,在某些情况下,如果A调用同步方法执行的时间比较长,B就会一直处于等待状态,也就是长期阻塞,所以此时使用同步代码块可能会更好(把一些不需要同步,并且消耗时间的代码块放在同步代码块外),效率上会提高一些。
锁定临界资源对象

public class DemoB<Integer> {
    Object obj = new Object();
    public void test() {
        synchronized (obj) {
            //...
        }
    }
}

同步代码块在执行的时候,锁定的是当前的对象。当多个线程调用同一个方法的时候,锁定对象不变的情况下,需要同步执行。
synchronized(obj)将obj对象本身作为了对象监视器
这个对象如果是实例变量的话,指的是对象的引用,只要对象的引用不变,即使改变了对象的属性,运行结果依然是同步的。
锁的是堆空间中的对象,而不是引用。

1、当多个线程同时执行 synchronized(object){} 同步代码块时呈同步效果

2、当其他线程执行 object 对象中的 synchronized 同步方法时呈同步效果

3、当其他线程执行 object 对象方法中的 synchronized(this) 代码块时也呈同步效果

4、在定义同步代码块时,不要使用常量对象作为锁目标对象。比如字符串常量、整形等。

锁定当前对象
当锁定的对象为this的时候,相当于同步方法。
锁定Class对象(同步静态方法)

public class DemoC {
    public static synchronized void eat(){
        System.out.println("eat");
    }
    public static void test(){
        synchronized (DemoA.class){
            System.out.println("lalalala");
        }
    }
}

sync加在静态方法上主要就是锁定了当前类的Class实例对象。
sync用在静态方法的同步代码块里,主要就是锁定了同步监视器的字节码文件的Class对象。
静态同步方法和非静态同步方法持有的是不同的锁,前者是类锁,后者是对象锁。
synchronized锁重入
关键字 synchronized 拥有锁重入的功能。所谓锁重入的意思就是:当一个线程得到一个对象锁后,再次请求此对象锁时可以再次得到该对象的锁的。 锁重入的实现是通过同一个线程,多次调用同步代码,锁定同一个锁对象,可重入。这种锁重入的机制,也支持在父子类继承的环境中。 子类同步方法覆盖父类同步方法。可以指定调用父类的同步方法。

public class DemoD {
    public synchronized static void sale(){
        del();
    }
    public synchronized static void del(){
        System.out.println("del");
    }

    public static void main(String[] args) {
        sale();
    }
}

发生异常自动释放锁

public class DemoE implements Runnable{

    private int flag=0;
    @Override
    public void run() {
        while (flag==5)
            throw new RuntimeException();
        flag++;
    }

}
class DemoEE{
    public static void main(String[] args) {
        DemoE e = new DemoE();
        for (int i=0;i<10;i++) {
            new Thread(e).start();
        }
    }
}

在这里插入图片描述
由图可见,当线程拿到锁执行的时候发生异常会自动释放锁资源。

方法传递之间的原子性问题

public class DemoF {
    private double d = 0.0;
    public synchronized void m1(double d){
        try {
            // 相当于复杂的业务逻辑代码。
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        this.d = d;
    }

    public double m2(){
        return this.d;
    }

    public static void main(String[] args) {
        final DemoF t = new DemoF();

        new Thread(new Runnable() {
            @Override
            public void run() {
                t.m1(100);
            }
        }).start();
        System.out.println(t.m2());
        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(t.m2());
    }
}

在这里插入图片描述
由此可以得知
同步方法只能保证当前方法的原子性,不能保证多个业务方法之间的互相访问的原子性。
开发中,多方法要求结果访问原子操作,需要多个方法都加锁,且锁定统一个资源。一般来说,商业项目中,不考虑业务逻辑上的脏读问题。在数据库上要考虑脏读。
锁的底层实现
Java 虚拟机中的同步(Synchronization)是基于进入和退出管程(Monitor)对象实现。同步方法并不是由 monitor enter 和 monitor exit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的。
在这里插入图片描述
对象内存简图
在这里插入图片描述
对象头:存储对象的 hashCode、锁信息或分代年龄或 GC 标志,类型指针指向对象的类元数据,JVM 通过这个指针确定该对象是哪个类的实例等信息。
实例变量:存放类的属性数据信息,包括父类的属性信息
填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐

当在对象上加锁时,数据是记录在对象头中。当执行 synchronized 同步方法或同步代码块时,会在对象头中记录锁标记,锁标记指向的是 monitor 对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如 monitor 可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor 是由 ObjectMonitor 实现的。
ObjectMonitor 中有两个队列,_WaitSet 和 _EntryList,以及 _Owner 标记。其中 _WaitSet 是用于管理等待队列(wait)线程的,_EntryList 是用于管理锁池阻塞线程的,_Owner 标记用于记录当前执行线程。
当多线程并发访问同一个同步代码时,首先会进入 _EntryList,当线程获取锁标记后,monitor 中的 _Owner 记录此线程,并在 monitor 中的计数器执行递增计算(+1),代表锁定,其他线程在 _EntryList 中继续阻塞。若执行线程调用 wait 方法,则monitor中的计数器执行赋值为0计算,并将 _Owner 标记赋值为 null,代表放弃锁,执行线程进如 _WaitSet 中阻塞。若执行线程调用 notify/notifyAll 方法,_WaitSet 中的线程被唤醒,进入 _EntryList 中阻塞,等待获取锁标记。若执行线程的同步代码执行结束,同样会释放锁标记,monitor中的 _Owner 标记赋值为 null,且计数器赋值为0计算。
synchronized 块获得的是一个对象锁,换句话说,synchronized 块锁定的是整个对象。
synchronized 取得的锁都是对象锁,而不是把一段代码或方法(函数)当作锁,哪个线程先执行带 synchronized 关键字的方法,哪个线程就持有该方法所属对象的锁,其他线程都只能呈等待状态。
最底层追的话,实际上synchronized是执行的lock comxchg指令。

5.锁的种类

Java中的锁大致分为

偏向锁
自旋锁
轻量级锁
重量级锁
锁的使用方式
先提供偏向锁,如果不满足的时候,升级为轻量级锁,再不满足,升级为重量级锁。自旋锁是一个过渡的锁状态,不是一种实际的锁类型。

注意:锁只能升级,不能降级。
重量级锁

同步方法和同步代码块中解释的就是重量级锁。
偏向锁

是一种编译解释锁。如果代码中不可能出现多线程并发争抢同一个锁的时候,JVM 编译代码,解释执行的时候,会自动的放弃同步信息。消除 synchronized 的同步代码结果。使用锁标记的形式记录锁状态。在 Monitor 中有ACC_SYNCHRONIZED。当变量值使用的时候,代表偏向锁锁定。可以避免锁的争抢和锁池状态的维护。提高效率。
轻量级锁

过渡锁。当偏向锁不满足,也就是有多线程并发访问,锁定同一个对象的时候,先提升为轻量级锁。也是使用标记ACC_SYNCHRONIZED 标记记录的。ACC_UNSYNCHRONIZED 标记记录未获取到锁信息的线程。就是只有两个线程争抢锁标记的时候,优先使用轻量级锁。
两个线程也可能出现重量级锁。
自旋锁

是一个过渡锁,是偏向锁和轻量级锁的过渡。
当获取锁的过程中,未获取到。为了提高效率,JVM 自动执行若干次空循环,再次申请锁,而不是进入阻塞状态的情况。称为自旋锁。自旋锁提高效率就是避免线程状态的变更。

猜你喜欢

转载自blog.csdn.net/weixin_45596022/article/details/108386079