java.util.concurrent 之 原子变量、unsafe以及硬件级别的同步实现

在java线程中,我们经常遇到要并发地操作一个数的情况,例如实时地统计某页面的浏览量,为了提高网络相应在北京,成都,南京,海南均搭建了提供该页面的服务。当然,如果我们统计时,在一个线程中不断地去切换服务地址来获取两地浏览量是非常没有效率的。所以我们可能考虑建立多个个线程来对我们的浏览量这个数据做增加的动作。
我们用下面的代码来模拟这个过程:

public class Adder implements Runnable {
    private int a = 0;
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            a = a + 1;
            System.out.println(a);
        }
    }
}
public class Test {

    public static void main(String[] args) {
        Adder adder = new Adder();
        Thread thread1 = new Thread(adder);
        Thread thread2 = new Thread(adder);
        Thread thread3 = new Thread(adder);
        Thread thread4 = new Thread(adder);

        thread1.start();
        thread2.start();
        thread3.start();
        thread4.start();
    }
}

这个代码,我们用了四个线程来模拟四地的浏览量,每个地方都浏览了100次,当然总浏览量是400次,但我们运行这个代码,老是得不到400这个结果,总是要少:
376
371
371
376
377
这当然是不准确的,这个不准确的根源在哪里呢?很明显我们就做了一个操作:

a = a + 1;

这个操作是线程不安全的,执行这个操作时,需要先获取a的值,但是此时如果有两个线程同时获取了a的值,这相当于有一个线程的执行结果丢失了,所以我们的浏览量总是会少。

怎么解决呢?在J.U.C出现之前,我们只能使用syncronized关键字来锁住相应的代码块,在这里就是a = a + 1这一句代码了,效率十分低下,即便1.6对syncronized关键字做了许多的优化。

J.U.C出现之后,对于这种操作,我们已经有了非常犀利的工具来处理了,那就是AtomicXXX类,基本涵盖了各种数据类型,我就简单说一下效果:
我们把Adder 类改成以下的实现方式,无论你怎么搞,结果绝对不会出现400以外的数字:

public class Adder implements Runnable {

    private AtomicInteger a = new AtomicInteger(0);
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            a.incrementAndGet();
            System.out.println(a.get());
        }
    }
}

我们可以把原子变量想象成在变量上加了一个syncronized关键字,使得这个变量操作起来是同步的。

我们还是来看到它的实现:

private static final long serialVersionUID = 6214790243416807050L;

    // setup to use Unsafe.compareAndSwapInt for updates
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    private volatile int value;
public final void lazySet(int newValue) {
        unsafe.putOrderedInt(this, valueOffset, newValue);
    }
public final int getAndSet(int newValue) {
        return unsafe.getAndSetInt(this, valueOffset, newValue);
    }
    ...

本身没有什么特别的操作,只是我们看到了各种unsafe操作,这个Unsafe类很神奇,你没办法在代码里用它,一用就报错如下:

Exception in thread “main” java.lang.SecurityException: Unsafe
at sun.misc.Unsafe.getUnsafe(Unknown Source)
at neuroph.Test.main(Test.java:9)

于是你就去看Unsafe的代码,发现还反编译不了,因为它在src.zip根本就没有,你想到JRE总要使用这个Unsafe吧,然后你在rt.jar中找到了这个Unsafe,打开一看里面加了这样一句(java8):

@CallerSensitive
public static Unsafe getUnsafe()
  {
    Class localClass = Reflection.getCallerClass(2);
    if (localClass.getClassLoader() != null) {
      throw new SecurityException("Unsafe");
    }
    return theUnsafe;
  }

这是什么意思?就是不让你调用的意思,我们简单了解一下类加载机制可以知道,localClass.getClassLoader() == null的情况只能出现在JVM启动时加载JRE中的lib文件夹中的内容时,即bootstrap loader加载时,所以整段代码意思就是:你不是写jre或者写jdk的,我不让你用。

所以我们可以了解到,虽然java是将开发者与系统底层的一些功能隔离开来保证代码的安全性,但事实上对自己开了个后门。AtomicXXX写进了jre的rt.jar,显然bootstrap loader是要去加载它了,所以它使用unsafe类就没有什么问题。

这个时候我们再来看看AtomicXXX类为什么能够在并发的情况下确保数据写入不出现结果丢失的情况。
首先这个unsafe类能干嘛呢,它能够改变某个内存地址上的值,当然了我们知道C、C++这些语言能够很轻松的做到这一点,在Java这,由于多了一层虚拟机,我们只能通过走后门来完成这样的功能了。

Unsafe类中满满的都是native方法,

 ......
  public native boolean tryMonitorEnter(Object paramObject);

  public native void throwException(Throwable paramThrowable);

  public final native boolean compareAndSwapObject(Object paramObject1, long paramLong, Object paramObject2, Object paramObject3);

  public final native boolean compareAndSwapInt(Object paramObject, long paramLong, int paramInt1, int paramInt2);

  public final native boolean compareAndSwapLong(Object paramObject, long paramLong1, long paramLong2, long paramLong3);

  public native Object getObjectVolatile(Object paramObject, long paramLong);

  public native void putObjectVolatile(Object paramObject1, long paramLong, Object paramObject2);

  public native Object allocateInstance(Class paramClass)
    throws InstantiationException;

  ......

我们可以看到throwException,allocateInstance这种基石级别的方法,事实上整个Java语言系统都是由这些功能构建的,他们代表了虚拟机执行你代码的过程,他作为后门使用就成为了虚拟机本身提供的接口,然后java设计者可以通过这些接口来操作整个虚拟机,进而操作整个操作系统,语言的设计思路都是相通的。

也就是说,Java的原子变量之所以具有原子性,完全没有什么设计,就是直接调用了Unsafe这个后门,这个后门是native方法,也许是一个什么dll,但是这个dll要实现cpu级别的线程同步也是需要操作系统本身提供的接口,那操作系统本身的接口又是怎么。。。好吧打住,我们直接来看硬件级别(CPU指令级别)上,intel架构,或者说目前流行的CPU多线程同步方式是怎样做的。

那么归根结底,线程问题就是cpu的多个线程对同一块存储区域(一般为CPU的一级缓存)的无序操作问题,我们只要保证了最底层的这个一般就是两种方式:
1.总线锁;2.缓存一致性协议

总线锁:某线程需要对某块内存或缓存进行操作,这个时候我需要阻断其他线程对这块缓存的访问怎么做?这时CPU就向总线发送一个lock#信号(别管这个#是什么意思,总之就是一个信号就对了),其他线程的执行者(发lock#信号的cpu的其他线程的时间片或其他线程)就不能再访问这块缓存了,这样子只有一个线程能够访问当前缓存,效率不是很高,也许某个线程发送这个lock#信号后干了一些其他的事情,并没有积极地去结束对总线的锁定,这就耽误时间了。所以我们再来看看缓存一致性协议。

缓存一致性协议:缓存一致性协议又叫MESI协议,因为他给缓存定义了四种不同类型的状态:
M: Modify,修改缓存,当前CPU的缓存已经被修改了,即与内存中数据已经不一致了
E: Exclusive,独占缓存,当前CPU的缓存和内存中数据保持一致,而且其他处理器并没有可使用的缓存数据
S: Share,共享缓存,和内存保持一致的一份拷贝,多组缓存可以同时拥有针对同一内存地址的共享缓存段
I: Invalid,实效缓存,这个说明CPU中的缓存已经不能使用了

然后呢,CPU对缓存的读取遵循如下规则:
CPU的读取遵循下面几点:

  1. 如果缓存状态是I,那么就从内存中读取,否则就从缓存中直接读取。

  2. 如果缓存处于M或E的CPU读取到其他CPU有读操作,就把自己的缓存写入到内存中,并将自 己的状态设置为S。

  3. 只有缓存状态是M或E的时候,CPU才可以修改缓存中的数据,修改后,缓存状态变为M。

    我们可以去试试写一段代码来模拟,搞几个寄存器,多线程去操作他们,按照以上规则来操作,每个线程读取状态是肯定一致的。但是这种也有一个问题,如果遇到大量的缓存修改请求,这种修改缓存状态的操作就会大量的占用总线资源,这个就叫做缓存一致风暴,对比总线锁的策略,是不是有点类似于以空间换时间的思想?

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值