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

<<Java并发编程的艺术>>读书笔记

工作中不怎么接触到多线程并发,(洒家还只时一头码畜),为了提升自己的内功,在江湖上也留下点足迹,不枉走这一遭.遂决定搜罗名家武学典籍,饱览群书.现在入手这本秘籍,源自武林泰斗方腾飞(江湖人称:清英).吾虽只研读小半月,但收获贼多.本想做个读书笔记把之前不懂的记录下来,结果…都不懂.遂抄书如下.如有错别字各位看官包含,都是洒家一字一字手打滴.下面是第二章整章的内容我做了少数的删减.要是有时间可以看看这本书,非常非常好,要是没时间可以到我这里扫几眼笔记那也极好.

前言:
Java代码编译->Java字节码->类加载器加载到JVM->JVM执行字节码->汇编指令在CPU上执行.

Java的并发机制依赖于JVM的实现和CPU的指令.

volatile的应用

volatile是轻量级synchronized,在多处理开发中保证了共享变量的"可见性".意思是A线程修改一个共享变量时,B线程可以读到这个修改值.volatile使用得当会比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度.

定义与实现原理

volatile声明的字段,Java线程内存模型确保所有线程看到这个变量的值是一致的

CPU术语定义

内存屏障(memory barriers)

是一组处理器指令,用于实现对内存操作的顺序限制.

缓冲行(cache line)

CPU高速缓存中可以分配的最小存储单元,处理器填写缓存行时会加载整个缓存行,现代CPU需要执行几百次CPU指令.

原子操作(atomic operations)

不可中断的一个或一系列操作.

缓存行填充(cache line fill)

当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个高速缓存行到适当的缓存

缓存命中(cache hit)

写命中(write hit)

写缺失(write misses the cache)

一个有效的缓存行被写入到不存在的内存区域


volatile变量修饰的共享变量进行写操作时,汇编码会多一行带Lock的指令,会导致多核处理器下发生两件事.

1)将当前处理器缓存的数据写回到系统内存.

2)这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效.

因为要提高处理速度,处理器不会直接和内存通信,是先将系统内存的数据读到内部缓存再进行操作,但操作完不知道何时会写到内存.如果对volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令.将这个变量所在的缓存行写回系统内存,并且多处理器下,会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存中里.

实现原则:

  1. Lock前缀指令会引起处理器缓存回写到内存.

  2. 一个处理器的缓存回写到内存会导致其他处理器的缓存无效.

volatile的使用优化

为什么追加64字节能提高并发编程的效率?

因为一般处理器的缓存的高速缓存行是64个字节宽,不支持部分填充缓存行,意味着如果队列的头节点和尾节点都不足64字节的话,会将他们读到同一个高速缓存行中,多处理器下,每个处理器都会缓存同样的,头,尾节点.当一个处理器试图修改头节点时,会将整个缓存行锁定,在缓存一致性机制的作用下,会导致其他处理器不能访问自己高速缓存中的尾节点,而队列的入队,出队操作需要不停修改头,尾节点.所以会严重影响队列入队,出队的效率.追加64字节可以避免头节点和尾节点加载到同一个缓存行,使头,尾节点在修改时不会互相锁定.

是不是使用volatile变量时都应该追加到64字节呢?

两种情况不应该使用这种方式:

  1. 缓存行非64字节宽的处理器

  2. 共享变量不会被频繁地写

追加字节需要处理器读取更多的字节到高速缓冲区,本身就是性能消耗,如果共享变量不被频繁写,那么锁的几率也非常小,没必要加字节来避免相互锁定.

  1. 另外Java7之后可能不生效,因为Java更智慧,它会淘汰或重新排列无用字段,需要其他追加字节的方式.

synchronized实现原理和应用

多线程并发编程中synchronized一直是元老级角色,多数人叫它重量级锁.

Java每个对象都可以作为锁.

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

线程访问同步代码块时,首先必须得到锁,退出或跑出异常时必须释放锁.

Synchronized在JVM里的实现原理,JVM基于进入和退出monitor对象来实现方法同步和代码块同步,二者实现细节不一样.

代码块同步使用monitorenter和monitorexit指令实现的.

方法同步的实现方式JVM规范没有详细说明.

monitorenter指令在编译后插入到同步代码块的开始位置,monitorexit插入到方法结束处和异常处理处.任何对象都有一个monitor关联,当且一个monitor被持有,它处于锁定状态.线程执行到monitorenter时,会尝试获取对象对应的monitor的所有权,也就是尝试获得对象的锁.

Java对象头

三部分组成

  • Mark Word(标志词)
  • Class Metadata Address(指向类的指针)
  • Array length 数组长度(只有数组对象才有)

Mark Word

记录对象和锁有关的信息,32位JVM中长度是32bit,64位JVM中长度是64bit.

默认存储对象的Hash Code,分代年龄和锁标记位.

32位时

Java对象头的存储结构:

25bit 对象的hashCode;

4bit 分代年龄;

1bit 是否偏向(0);

2bit 锁标志位(01);

运行期间,里面存储的数据会随着锁标志位变化而变化.

状态变化如下:

  1. 轻量级锁:指向栈中锁记录的指针,标志位(00);
  2. 重量级锁:指向互斥量的指针,标志位(10);
  3. GC标记:锁标记(11)
  4. 偏向锁:线程ID,Epoch,对象分代年龄,是否偏向锁(1),锁标志位(01);

锁的升级与对比

Java SE 1.6 减少获得锁和释放锁带来的性能开销,引入"偏向锁",“轻量级锁”

1.6中,锁有四种状态,级别从低到高:无锁->偏向锁->轻量级锁->重量级锁

1.偏向锁

大多数情况,锁不存在多线程竞争,总是由同一线程多次获得,为了线程获得锁的代价更低,引入偏向锁.对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程进入和退出同步代码块时,不需要进行CAS操作来加锁和解锁,只需要测试对象头的指向是否指向当前线程的偏向锁,如果成果则获取锁,如果失败则再次测试Mark Word中的偏向锁标识是否设置为1;如果没有设置,则采用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程.

撤销:

等到竞争出现才会释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁.偏向锁的撤销需要等待全局安全点(在这个时间点上没有正在执行的的字节码).会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,将对象头设置为无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,便利偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程.

关闭:

偏向锁在Java6,java7中默认启用,但在应用程序启动几秒钟后才激活,如果有必要可以使用JVM参数关闭延迟:-XX:BiasedLockingStartupDelay=0.如果确定程序里所有的锁通常处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,这样程序默认进入轻量级锁状态.

2.轻量级锁

加锁

线程执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,.然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针.如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程尝试使用自旋来获取锁.

解锁

轻量级锁解锁时,使用原子的CAS操作,将锁记录替换会到对象头,如果成功,表示没有竞争发生,如果失败,则表示当前锁存在竞争,锁就会膨胀成为重量级锁.

因为自旋会消耗CPU,为了避免无用自旋(比如获得锁的线程被阻塞了),一旦锁升级成为重量级锁,就不会再恢复到轻量级锁状态.当处于这个状态下,其他线程试图获得锁时,都会被阻塞住,持有锁的线程释放锁后会唤醒这些线程,被唤醒的线程进行新一轮的夺锁争夺.

3.优缺点

偏向锁:

优点:

加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距.

缺点:

如果线程存在锁竞争,会带来额外的锁撤销的消耗.

适用场景:

适用于只有一个线程访问同步块场景.

轻量级锁:

优点:

竞争的线程不会阻塞,提高了程序响应速度.

缺点:

如果始终得不到锁竞争的线程,使用自旋会消耗CPU.

适用场景:

追求响应时间,同步块执行速度非常快.

重量级锁:

优点:

线程竞争不适用自旋,不会消耗CPU

缺点:

线程阻塞,响应时间慢.

适用场景:

追求吞吐量,同步块执行速度较长.

原子操作实现原理

Intel处理器中Java如何实现原子操作.

1.术语

  1. 缓存行 Cache line

    缓存的最小操作单位.

  2. 比较并交换 Compare and Swap

    CAS 需要输入两个值,一个旧值(期望值)一个新值,在操作期间先比较旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换.

  3. CPU流水线 CPU pipeline

    类似工厂生产上的装配流水线,CPU中由5-6个不通功能的电路单元组成一条指令处理流水线,然后将一条X86指令分成5-6步后再由这些电路单元分别执行,实现一个CPU的时间周期完成一条指令,提高CPU运算速度.

  4. 内存顺序冲突 Memory order violation

    一般由假共享引起,假共享指多个CPU同时修改同一个缓存行的不同部分引起其中一个CPU操作无效,当出现这个内存顺序冲突时,CPU必须清空流水线.

2.处理器如何实现原子操作

32位处理器基于缓存加锁或总线加锁的方式实现多处理器间的原子操作.处理器会自动保证基本的内存操作的原子性,处理器保证从系统内存中读取或者写入一个字节是原子的,意思是一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址.最新的处理器可以保证单处理器对同一个缓存行里进行的操作是原子的,但是复杂的内存操作处理器是不能自动保证其原子性的,比如跨总线宽度,跨多个缓存行和跨页表的访问.但是处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性.

1.总线锁保证原子性(开销大)

2.缓存锁保证原子性

两种情况不会使用缓存锁定

1.操作数据不能被缓存在处理器内部,或操作的数据跨多个缓存行时,则处理器会调用总线锁定.

2.有些处理器不支持缓存锁定.Intel 486 和Pentium处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定.

3.Java如何实现原子操作

Java中可以通过锁和循环CAS的方式来实现原子操作.

(1) 使用缓存CAS实现原子操作.

JVM的CAS操作,是利用处理器提供的CMPXCHG指令实现的.自旋CAS实现的基本思路就是循环进行CAS操作直到成功为止,以下代码实现了一个基于CAS线程安全的计数器方法cafeCount和一个非线程安全的计数器count

package com.cloud.threadSafe.cas;

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

public class Counter {
    private AtomicInteger atomicI = new AtomicInteger();
    ;
    private int i = 0;

    public static void main(String[] args) {
        final Counter cas = new Counter();
        List<Thread> ts = new ArrayList<>(600);
        long start = System.currentTimeMillis();
        for (int j = 0; j < 100; j++) {
            Thread t = new Thread(() -> {
                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);//992822
        System.out.println(cas.atomicI.get());//1000000
        System.out.println(System.currentTimeMillis() - start);//149
    }

    /**
     * CAS实现线程安全计数器
     */
    private void safeCount() {
        for (; ; ) {
            int i = atomicI.get();
            //如果当前值==预期值,则自动将该值设置为给定的更新值
            boolean b = atomicI.compareAndSet(i, ++i);
            if (b) {
                break;
            }
        }
    }

    private void count() {
        i++;
    }
}

Java1.5开始,JDK的并发包提供了一些类支持原子操作,如AtomicBoolean(用原子方式更新的boolean值),AtomicInteger(原子方式更新int值),AtomicLong(原子方式更新long的值),还有一些有用的工具方法,比如原子方式的自增,自减.

(2) CAS实现原子操作的三大问题

ABA问题,循环时间长开销大,只能保证一个共享变量的原子操作.

ABA

如果A->B->A,CAS时,会发现它的值没有变,但实际却变化了,可以加版本号,

变成1A->2B->3A. 从Java5开始,提供了AtomicStampedReference来解决ABA问题.

循环时间长开销大

JVM如果支持pause指令,效率会有提升,pause有两个作用

1.延迟流水线执行指令,使CPU不会消耗过多执行资源.

2.避免退出循环时因为内存顺序冲突引起CPU流水线被清空,提高CPU执行效率.

只能保证一个共享变量的原子操作

对一个共享变量执行操作时,可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这时候就可以用锁.也可以把多个共享变量合并成一个共享变量来操作.比如:i=2,j=a, 合并为 ij = 2a,然后用CAS来操作ij,Java1.5开始,提供了AtomicReference类保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作.

(3) 使用锁机制实现原子操作

锁机制保证了只有获得锁的线程才能够操作锁定的内存区域.JVM内部实现了很多种锁机制,有偏向锁/轻量级锁和互斥锁.除了偏向锁,JVM实现锁的方式都用来循环CAS,线程进入同步块时使用循环CAS获得锁,退出同步块的时候使用循环CAS释放锁.

总结

Java中大部分容器和框架都依赖volatile和原子操作的实现原理.

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值