《Java并发编程艺术》第二章 Java并发机制的底层实现原理


第二章 Java并发机制的底层实现原理

框架图

高清图片地址

高清图片地址


volatile的应用

简单介绍:是一个轻量级的synchronized,比它使用和执行成本更低,因为不会引起线程上下文的切换和调度。保证了共享变量的可见性,可见性就是当一个线程修改了共享变量后,另外一个线程能读到修改的值。

volatile的定义和实现原理

属性:如果字段被声明成volatile,则java线性内存模型确保所有的线程看到的这个变量的值是一致的。

一些术语,在理解下面的实现原理时会用到:

加入某线程对volatile声明的变量执行了写操作,则会在多核处理器中引发下面两件事:1、将当前处理器的缓存行的数据写回到系统内存。2、其他处理器中缓存了该变量的内存地址无效,要重新从内存加载。

下面讲原理,首先要知道为什么要用缓存行?,使用缓存是为了提高处理速度,处理器不直接和内存通信,而是通过将内存数据读取到缓存,然后处理器与缓存连接来实现的,这样减少了内存读写操作,会提高速度。

第一件事原理:首先,Lock信号一般是不锁总线的,而是锁缓存,因为锁总线成本很高,所以要点就在于缓存的变化。执行了写操作以后,缓存中和内存中的数据就不一样了,要写到内存中,但是在这里要注意,有可能两个线程同时进行了写操作,同时改变了自己的缓存呢?这里使用了缓存一致性机制来确保修改的原子性,这个操作被称为”缓存锁定“,有了这个就不会出现多个处理器同时修改缓存中同一地址的内容了。

第二件事原理:既然内存数据改了,那么其他处理器的缓存也就过期了,而每个处理器都会通过嗅探在总线传播的数据上看自己的缓存是否过期,如果检测到了其他的处理器打算写内存地址,而且这个地址是共享的,那么嗅探的处理器就会让自己的缓存行失效,等下次要读取相同内存的时候,再执行缓存行填充,重新加载。


volatile的使用优化

方法:在高速缓存行为64个字节宽的处理器中,把共享变量扩充到64位。

原理:因为有些处理器不支持部分填充缓存行,这样就会导致同一个高速缓存行可能存有多个共享变量。当对共享变量的操作很频繁时,若正在操作第一个共享变量,则整个缓存行都会被锁住,第二个共享便来给你的操作就需要等待,会严重影响效率,所以用追加到64字节的方式来填满高速缓冲区的缓存行,避免多个共享变量加载到同一个中,不会相互锁定。

不适用场景:1、处理器的高速缓存行不是64位。2、不需要频繁操作共享变量,多读取字节也是要消耗性能的。


synchronized的实现原理与应用

使用sync同步的基础:Java中每一个对象都可以作为锁。

同步具体表现

  • 对于普通同步方法,锁是当前实例对象。
  • 对于静态同步方法,锁是当前类的Class对象。
  • 对于同步方法块,锁是Synchronized括号里的配置对象。

(还没写过代码,没什么体会)

使用:当一个线程访问同步代码块时,首先必须得到锁,退出或抛出异常的时候必须释放锁。

锁的原理
JVM基于进入和退出Monitor对象来实现方法和代码块的同步,mintorenter在编译后插入到同步代码块开始的位置;mintorexit插入到方法结束或异常处,必须保证每个mintorenter都要有对应的mintorexit配对。同时任何对象都有一个mintor关联,根据这个mintor来决定是否处于锁的状态。
线程执行到了mintorenter就会尝试获取对象对应的mintor的所有权。

插入是把其余的所有插到同步里面?


Java对象头

sync用到的锁在java对象头里。

java对象头长度(这块知识和JVM对应上了):

Mark Word里面是:默认存储对象的HashCode、分代年龄、锁标记位。

java对象头的存储结构:

Mark Word里的存储数据随着锁标志位的变化而变化

Mark Word的状态变化:


锁的升级与对比

锁有四种状态,级别从低到高一次是:无锁、偏向锁、轻量级锁、重量级锁,状态会随着竞争情况逐渐升级。锁可以升级但不能降级,目的是为了提高获取和释放锁的效率(先猜一下,策略固定?)

偏向锁

创建原因:大多数情况下锁不仅不存在多线程竞争,还总是被同一线程获得,所以引入偏向锁让线程获取锁代价更低。就是说这个锁和这个进程都是老熟人了,结合也不用那么多手续了,效率更高。

流程

  • 已偏向:当线程到了这个同步代码块的时候,会在Java对象的对象头(Mark Word)(这个java对象就是锁吧)和栈帧的锁记录中记录这个线程id,以后这个来就不需要进行CAS来加锁解锁了。下次线程来的时候,就检查一下对象头的Mark word里有没有这个线程,有的话就算成了。
  • 未偏向:如果对象头的Mark Word里面没有指向该线程的偏向锁,那么就检查一下Mark Word中偏向锁的标识是否是1(1表示是偏向锁),是的话就尝试用CAS将对象头的偏向锁指向当前线程(偏向锁只能指向一个)。

锁记录:当字节码解释器执行monitorenter字节码轻度锁住一个对象时,就会在获取锁的线程的栈帧(调用方法的时候创建的,栈帧是指为一个函数调用单独分配的那部分栈空间,所以调用的是什么函数?)上显式或者隐式分配一个lock record(这个记录记录自己的线程id?那意义是啥?)
作用:

  • 持有displaced word和锁住对象的元数据
  • 解释器使用lock record来检测非法的锁状态
  • 隐式地充当锁重入机制的计数器

撤销
等到竞争激烈的时候才释放的机制,只有其他线程尝试竞争锁的时候,持有的线程才会释放(你不要我就一直拿着,你开口了我才给)。

撤销流程:首先得把有锁的线程暂停了,然后检查这个线程是否处于活动状态,不活动的话就把对象头设置成无锁状态(都挂了,谁也不认识谁了)。如果活着,也得重新搞一搞,此时要遍历拥有偏向锁的栈,遍历其中偏向对象的锁记录(看看都跟谁有关系),栈中的锁记录和对象头的Mark Word要么重新偏向其他线程(找个新相好),要么恢复无锁(自己过),要么标记为不适合作为偏向锁(自己不行,没法跟别人好),都做完了再把暂停的线程唤醒。

关闭偏向锁
在java6和java7里面是默认启动,但是有延迟,可用参数-XX:BiasedLockingStartupDelay=0来关闭延迟;如果所有的锁通常都处于竞争状态,那么可以用参数-XX:-UseBiasedLocking=false关闭,程序会默认进入轻量级。


轻量级锁

加锁:现在有一个线程和一个锁,线程想要这个锁,此时JVM会在这个线程的栈帧里面创建一个用来存储锁记录的空间,将对象头中的Mark Word复制到锁记录里,这个复制的Mark Word被称为Displaced Mark Word。此时线程自己这边准备好了,就开始竞争锁了,会尝试用CAS将对象头中的Mark Word替换为指向锁记录的指针,成功了就获得,没成功说明被其他线程抢走了,这会该线程就尝试用自旋获得锁。
(先是线程中存放了锁的信息,然后想把锁里的信息改成和这个相关的,这样两个就关系起来了)。

解锁:使用原子的CAS操作将Displaced Mark Word替换回对象头,如果成功,则没有发生竞争;如果失败,表示存在竞争,锁会膨胀成为重量级锁。(这块是不是不严谨,此时是不是已经变成重量级锁了?)
(不用了就把锁的信息还原,但是还原咋还能失败了?
答:应该是因为锁已经变成因为线程2的争夺变成膨胀锁了,此时轻量级的Mark Word已经没用了,所以还不回去了,对象头的Mark Word已经指向了重量级锁的指针。)
膨胀原因:锁已经被用了,看下面的图,是线程2导致锁膨胀的,膨胀后也把自己变阻塞了,省了部分自旋消耗。)

自旋:因为自旋会消耗CPU,所以也不能让自旋一直进行(如果带有锁的线程被阻塞了,锁出不来了,那么就会一直进行),此时锁会升级为重量级锁,不会再恢复到轻量级锁状态。当锁处于重量级时,如果其他线程试图获取锁,而锁此时还在被使用,那么这些申请的线程就会被阻塞住(这样就不会不停自旋了),当持有锁的线程释放锁后就会唤醒这些线程,被唤醒的线程就会进行新一轮的锁争夺。

膨胀流程图,非常清晰,有啥疑问看看这个就懂了。:


锁的优缺点对比

膨胀过程


原子操作的实现原理

原子操作:不可被中断的一个或一系列操作。

CPU术语定义:

流水线,感觉是一个活分五、六个人干,如果出问题了这一条线全清除。


处理器如何实现原子操作

前提条件:处理器会自动保证基本的内存操作的原子性,当一个处理器读取(包含读和写操作)一个字节的时候,其他的处理器就不能访问这个字节的内存地址了。

两种方法,使用总线锁和缓存锁(前面讲过了呀):

  • 总线锁:保证CPU1读、该、写共享数据的时候,CPU2不能操作缓存了该共享变量内存地址的缓存,具体操作是CPU1提供一个LOCK#信号输出到总线上,这个时候其他处理器的请求会被阻塞,那么该处理器就可以独占共享内存。(只修改一点,但是全都给锁上了)。
  • 缓存锁:只需要保证共享变量的内存地址的操作是原子性的,总线锁的缺点在于其他的内存地址也不能操作了,操作是修改内部的内存地址,允许它的缓存一致性机制来保证操作的原子性。

缓存一致性:阻止同时修改两个以上处理器缓存的内存区域数据。

不使用缓存锁定的情况

  • 操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行。(跨多个缓存行怎么说?好多地方用到一个数据?还是一个数据不同部分被多个用到?如数组?)(也相当于多个处理器同时缓存了这个区域吧)
  • 有的处理器不支持缓存锁定。

Java如何实现原子操作

java相关知识

  • 原子操作类:带有Atomic前缀,这英文就是”原子性“的意思,如AtomicBooleanAtomicInteger
  • AtomicInteger中的方法:compareAndSet(int expect, int update):如果现在的AtomicInteger类型的变量值为expect,那么就用update中的值更新这个值,成功了就返回true,失败了就返回false,这个更新操作是原子的。
  • 对于一个final变量,如果是基本数据类型的变量,则其数值一旦在初始化之后便不能更改;如果是引用类型的变量,则在对其初始化之后便不能再让其指向另一个对象。
  • Thread.join():等待直到这个线程死亡。

代码

package C2;


import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicStampedReference;

public class CAS {
    // 原子安全变量
    private AtomicInteger atomicInt = new AtomicInteger(0);
    // 普通的变量
    private int i = 0;
    // private AtomicStampedReference<Integer> aa =new AtomicStampedReference<>(0,0);

    public static void main(String[] args) {
        final CAS cas = new CAS();

        List<Thread> ts = new ArrayList<Thread>(600);
        // 计时
        long start = System.currentTimeMillis();
        // 创建了100个线程,每个线程都累计叠加10000
        for (int j = 0; j < 100; j++) {
            Thread t = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        cas.count();
                        cas.safeCount();
                    }
                }
            });
            ts.add(t);
        }
        // 全部开启,以此检测原子性
        for (Thread t : ts){
            t.start();
        }
        for (Thread t : ts){
            try {
                // 该方法就是等待直到线程死亡,保证所有线程都完成
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // 比较结果
        System.out.println(cas.i);
        System.out.println(cas.atomicInt.get());
        System.out.println(System.currentTimeMillis() - start);
    }
    // 使用CAS实现线程安全计数器
    private void safeCount(){
        // 自旋,反复请求,直到成功
        for (;;){
            int i = atomicInt.get();
            // 如果第一个参数和现在的值相等,就更新为第二个值
            // 但是本来就是get出来的,有什么不相等的理由呢?
            // 这里有两步,读、写,可能读出来之后,还没来得及写,就变了,此时就不写
            // 重新读,直到读与写之间,atomicInt没有被改变
            boolean suc = atomicInt.compareAndSet(i, ++i);
            if (suc){
                break;
            }
        }
    }

    private void count(){
        // 简单粗暴,但是可能会有原子性问题,结果会小于等于应该有的值
        i++;
    }
}

CAS的三大问题

  • ABA问题:CAS操作的时候需要检查,但是如果读的时候是A,在检查之间A变成了B,然后又变成了A,此时就会检查值没有发生那个变化,但实际上是发生了变化。解决方法是添加版本号,每变化一次版本号就加一,检查的时候版本号和数值两个都检查,用AtomicStampedReference类来实现。
  • 循环时间长开销大:自旋如果长时间不成功,就会带来非常大的执行开销,如果有pause指令的话,会好一点。(那到底是有还是没有呢?)
  • 只能保证一个共享变量的原子操作:对多个共享变量操作时,循环CAS无法保证操作的原子性,可以用锁(为啥无法保证,把检查的结果并在一起不行吗?);除此之外,可以把多个共享变量合成一个。Java 1.5之后提供了AtomicReference类来保证引用对象之间的原子性。

使用锁机制实现原子操作:只有获得锁的线程才可以操作锁定的内存区域。除了偏向锁,JVM获取和释放锁的时候都用了循环CAS方式。(循环CAS就是自旋呗)

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值