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

参考 JAVA并发编程的艺术

Java代码的一生:

  • 编译后变成Java字节码
  • 字节码被类加载器加载到jvm
  • jvm执行字节码,最终转换为汇编指令在CPU上运行

Java中使用的并发机制依赖于jvm的实现和CPU指令

1 volatile

1.1 volatile的应用

volatile是轻量级的synchronized. 在多处理器开发中保证共享变量的可见性volatile不会引发线程上下文的切换,比synchronized成本更低。

可见性:一个线程修改共享变量后,另外一个线程能读到修改后的值

1.2 volatile的定义

Java提供了volatile,比排它锁更方便。如果一个字段被修饰成volatile,Java内存模型会保证所有线程看到的值都是一致的。

1.3 volatile的底层实现

volatile如何保证可见性:

修饰的变量在转换为汇编指令时,会有一个Lock前缀的指令

这个指令引发两件事:
1. 将当前处理器缓存行的数据写回到系统内存
2. 写回内存的操作会使在其他CPU里缓存了该内存地址的数据失效

x86处理器使用MESI(修改、独占、共享、无效)控制协议维护内部缓存和其他处理器缓存的一致性。

2 synchronized

synchronized实现同步的基础:
Java中的每一个对象都可以是锁。

  • 普通同步方法: 锁是当前实例对象
  • 静态同步方法: 锁是当前类的class对象
  • 同步方法块: 锁是括号里配置的对象

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。

2.1 实现原理

jvm规范中可以看到synchronized的实现原理:

  • jvm通过进入和退出Monitor对象来实现方法同步和代码块同步,但实现细节不一样
  • 代码块:使用monitorenter和monitorexit指令实现
  • 方法同步: 另外一种,jvm规范中细节没有说明

monitorenter和monitorexit指令:

  • monitorenter指令:在编译后期插入到同步代码块开始位置
  • monitorexit指令: 插入到方法结束和异常处
  • jvm保证每个monitorenter都有一个monitorexit对应。
  • 任何一个对象都有一个monitor对象关联。并且一个monitor对象被持有后,将处于锁定状态
  • 线程执行到monitorenter指令时,会尝试获取对象所对应的monitor的所有权,即尝试获取对象的锁

2.2 java对象头

synchronized用的锁是存在java对象头里的。

jvm用3个字宽存储数组类型对象头,其他类型用2个字宽。

32位的java虚拟机一个字宽是32位,即4个字节;
64位的java虚拟机一个字宽是64位,即8个字节

java对象头的长度说明:

长度内容说明
32/64bitmark word存储对象的hashCode或锁信息等
32/64bitclass metadata address存储到对象类型的数据的指针
32/64bitarray length如果是数组类型表示数组长度

Java对象头里的Mark Word里默认存储对象的HashCode、分带年龄和锁标记位。

32位JVM的Mark Word的默认存储结构:

锁状态25bit4bit1bit 是否偏向锁2bit 锁标志位
无锁状态对象的hashCode对象分代年龄001

运行期间,Mark Word里面存储的数据会随着锁标志位的变化而变化。Mark Word可能变化为存储以下4中数据:

锁状态25bit[23bit + 2bit]4bit1bit[是否偏向锁]2bit [锁标志位]
轻量级锁指向栈中锁记录的指针(包括后面5bit)00
重量级锁指向互斥量(重量级锁)的指针(包括后面5bit)10
GC标记空(包括后面5bit)11
偏向锁线程ID + Epoch对象分带年龄101

工具参考:
查看java对象头:http://openjdk.java.net/projects/code-tools/jol/
查看类的字节码:http://blog.csdn.net/qq_24489717/article/details/53837493

2.3 锁的升级与对比

  • Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。

  • 在Java SE1.6中,锁一共有4中状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态

  • 这几个状态会随着竞争情况逐渐升级。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

2.3.1 偏向锁

引入偏向锁的原因:
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。

下图为偏向锁的获得和撤销流程:
image

偏向锁的获得

当一个线程访问同步块并获取锁时,会在对象头和线程栈帧中的锁记录里存储锁偏向的线程ID(通过最开始的一次CAS),以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁,而只需简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁,如果测试成功,表示线程已经获得了锁,如果测试失败,则需要再测试下Mark Word中偏向锁的标识是否设置成1(看看当前是否还处于偏向锁的层次,因为锁会升级的),如果设置了,则当前仍处于偏向锁层次只是还没有线程此刻占有锁,尝试使用CAS将对象头的偏向锁指向当前线程(释放锁时,会将对像头中纪录线程id的这个位置置空,以便其他线程获取该偏向锁);如果没有设置,表示当前可能已经升级到轻量锁甚至重量锁了,则使用CAS竞争锁。
如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会尝试消除它身上的偏向锁,将锁恢复到标准的轻量级锁。(偏向锁只能在单线程下起作用)

偏向锁的撤销

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

2.3.1 轻量锁

image

轻量级锁也是一种多线程优化,它与偏向锁的区别在于,轻量级锁是通过CAS来避免进入开销较大的互斥操作,而偏向锁是在无竞争场景下完全消除同步,连CAS也不执行(CAS本身仍旧是一种操作系统同步原语,始终要在JVM与OS之间来回,有一定的开销)。

轻量级锁(Lightweight Locking)本意是为了减少多线程进入互斥的几率,并不是要替代互斥。
它利用了CPU原语Compare-And-Swap(CAS,汇编指令CMPXCHG),尝试在进入互斥前,进行补救。

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

轻量级锁解锁:轻量级解锁时,会使用原子的CAS操作来将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。上图是两个线程同时争夺锁,导致锁膨胀的流程图。

参考:http://blog.sina.com.cn/s/blog_c038e9930102v2ht.html

2.4 锁的优缺点对比

优点缺点适用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距如果线程间存在锁竞争,会带来额外的锁撤销的消耗适用于只有一个线程访问同步块的场景
轻量级锁竞争的线程不会被阻塞,提高了程序的响应速度如果始终得不到锁的线程,使用自旋会消耗CPU追求响应时间
重量级锁线程竞争不使用自旋,不会消耗CPU线程被阻塞,响应时间缓慢追求吞吐量同步块执行时间较长

3 原子操作的实现

3.1 处理器实现原子操作

当处理器读取内存的一个字节时,其它处理器不能访问这个字节的内存地址,最新的处理器能自动保证处理器对同一缓存行里进行16/32/64位的操作是原子的。处理器提供总线锁定和缓存锁定的机制保证复杂内存操作的原子性。

1、总线锁保证原子性

使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其它处理器的请求将被阻塞,那么该处理器就能独自共享内存。

2、缓存锁保证原子性

“缓存锁定”指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不需要在总线上声言LOCK#信号,而是修改内部的内存地址,通过缓存一致性机制保证操作的原子性。
例外:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行,处理器会调用总线锁定。

缓存一致性

缓存一致性会阻止同时修改由两个以上处理器的内存区域数据,当其他处理器回写被锁定的缓存行数据时,会使其它处理器的缓存行无效。

3.2 Java原子操作实现

在Java中通过循环CAS的方式实现原子操作。

3.2.1 使用循环CAS实现原子操作

jvm中的CAS操作是基于处理器的CMPXCHG指令实现的,java1.5开始并发包中提供了原子操作的类,如AtomicInteger、AtomicBoolean等,下面试一段代码:

package cas;

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

public class Counter {
    private AtomicInteger count1 = new AtomicInteger(0);
    private int count2 = 0;

    public static void main(String[] args) {
        final Counter counter = new Counter();
        List<Thread> threadList = new ArrayList<Thread>(100);
        for (int i = 0; i < 100; i++) {
            Runnable runnable = new Runnable() {
                public void run() {
                    for (int j = 0; j < 1000; j++) {
                        counter.countAtomic();
                        counter.count();
                    }
                }
            };
            threadList.add(new Thread(runnable));
        }

        long start = System.currentTimeMillis();
        for (Thread t : threadList) {
            t.start();
        }

        for (Thread t : threadList) {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        System.out.println("time:" + (System.currentTimeMillis() - start));
        System.out.println("count1:" + counter.count1.get());
        System.out.println("count2:" + counter.count2);
    }

    public void countAtomic() {
        for (; ; ) {
            int t = count1.get();
            boolean success = count1.compareAndSet(t, t + 1);
            if (success) {
                break;
            } else {
                System.out.println("cas fail!");
            }
        }
    }

    public void count() {
        count2++;
    }
}

3.2.2 CAS存在三个问题

3.2.2.1 ABA问题

如果另一个线程修改V值假设原来是A,先修改成B,再修改回成A。当前线程的CAS操作无法分辨当前V值是否发生过变化。

解决方法:

ABA问题解决方法就是加版本号。从java1.5开始,JDK的atomic包提供一个类AtomicStampedReference来解决ABA问题。

AtomicStampedReference内部不仅维护了对象值,还维护了一个版本号。当AtomicStampedReference对应的数值被修改时,除了更新数据本身外,还必须要更新版本号。当AtomicStampedReference设置对象值时,对象值以及版本号都必须满足期望值,写入才会成功。因此,即使对象值被反复读写,写回原值,只要版本号发生变化,就能防止不恰当的写入。

下面为AtomicStampedReference的实例代码:

package cas;

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

public class AtomicStampedReferenceDemo {
    public static AtomicStampedReference<Integer> money = new AtomicStampedReference<Integer>(0, 0);

    public static void main(String[] args) {
        List<Thread> threadList = new ArrayList<Thread>();
        for (int i = 0; i < 10; i++) {
            Runnable runnable = new Runnable() {
                public void run() {
                    for (int j = 0; j < 100; j++) {
                        int stamp = money.getStamp();
                        int m = money.getReference();
                        boolean success;
                        if (j % 2 == 0) {
                            success = money.compareAndSet(m, m + 10, stamp, stamp + 1);
                            if (!success){
                                System.out.println("recharge fail");
                            }
                        } else {
                            if (m<=0){
                                System.out.println("money is zero");
                                continue;
                            }
                            success = money.compareAndSet(m, m - 10, stamp, stamp + 1);
                            if (!success){
                                System.out.println("consume fail");
                            }
                        }

                    }
                }
            };
            threadList.add(new Thread(runnable));
        }

        for (Thread t : threadList) {
            t.start();
        }

        for (Thread t : threadList) {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        System.out.println("money:"+money.getReference());
        System.out.println("stamp:"+money.getStamp());
    }
}
3.2.2.2 循环时间长开销大

自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。

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

当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值