论坛首页 Java版 企业应用

多核线程笔记-volatile原理与技巧

浏览 15158 次
该帖已经被评为精华帖
作者 正文
最后更新时间:2008-07-30

volatile, 用更低的代价替代同步

为什么 使用volatile比同步代价更低?
同步的代价, 主要由其覆盖范围决定, 如果可以降低同步的覆盖范围, 则可以大幅提升程序性能. 

而volatile的覆盖范围仅仅变量级别的. 因此它的同步代价很低.

volatile原理是什么?
volatile的语义, 其实是告诉处理器, 不要将我放入工作内存, 请直接在主存操作我.(工作内存详见java内存模型)

因此, 当多核或多线程在访问该变量时, 都将直接
操作 主存, 这从本质上, 做到了变量共享.

volatile的有什么优势?
1, 更大的程序吞吐量
2, 更少的代码实现多线程
3, 程序的伸缩性较好
4, 比较好理解, 无需太高的学习成本

volatile有什么劣势?
1, 容易出问题
2, 比较难设计




volatile运算存在脏数据问题

volatile仅仅能保证变量可见性, 无法保证原子性.

volatile的race condition示例:

public class TestRaceCondition {
    private volatile int i = 0;
   
    public void increase() {
       i++;
    }

    public int getValue() {
       return i;
    }
}

当多线程执行increase方法时, 是否能保证它的值会是线性递增的呢?
答案是否定的.

原因:
这里的increase方法, 执行的操作是i++, 即 i = i + 1;
针对i = i + 1, 在多线程中的运算, 本身需要改变i的值.
如果, 在i已从内存中取到最新值, 但未与1进行运算, 此时其他线程已数次将运算结果赋值给i.
则当前线程结束时, 之前的数次运算结果都将被覆盖.

即, 执行100次increase, 可能结果是 < 100.
一般来说, 这种情况需要较高的压力与并发情况下, 才会出现.

如何避免这种情况?
解决以上问题的方法:
一种是 操作时, 加上同步.
这种方法, 无疑将大大降低程序性能, 且违背了volatile的初衷.

第二种方式是, 使用硬件原语(CAS), 实现非阻塞算法
从CPU原语上,  支持变量级别的低开销同步.




CPU原语-比较并交换(CompareAndSet),实现非阻塞算法

什么是CAS?
cas是现代CPU提供给并发程序使用的原语操作. 不同的CPU有不同的使用规范.

在 Intel 处理器中,比较并交换通过指令的 cmpxchg 系列实现。
PowerPC 处理器有一对名为“加载并保留”和“条件存储”的指令,它们实现相同的目地;
MIPS 与 PowerPC 处理器相似,除了第一个指令称为“加载链接”。


CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)

什么是非阻塞算法?
一个线程的失败或挂起不应该影响其他线程的失败或挂起.这类算法称之为非阻塞(nonblocking)算法

对比阻塞算法:
如果有一类并发操作, 其中一个线程优先得到对象监视器的锁, 当其他线程到达同步边界时, 就会被阻塞.
直到前一个线程释放掉锁后, 才可以继续竞争对象锁.(当然,这里的竞争也可是公平的, 按先来后到的次序)

CAS 原理:

我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。

CAS使用示例(jdk 1.5 并发包 AtomicInteger类分析:)

  

 /**
     * Atomically sets to the given value and returns the old value.
     *
     * @param newValue the new value
     * @return the previous value
     */
    public final int getAndSet(int newValue) {
        for (;;) {
            int current = get();
            if (compareAndSet(current, newValue))
                return current;
        }
    }

    public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }
 


这个方法是, AtomicInteger类的常用方法, 作用是, 将变量设置为指定值, 并返回设置前的值.
它利用了cpu原语compareAndSet来保障值的唯一性.

另, AtomicInteger类中, 其他的实用方法, 也是基于同样的实现方式.
比如 getAndIncrement, getAndDecrement, getAndAdd等等.


CAS语义上存在的 "
ABA 问题"

什么是ABA问题?
假设, 第一次读取V地址的A值, 然后通过CAS来判断V地址的值是否仍旧为A, 如果是, 就将B的值写入V地址,覆盖A值.

但是, 语义上, 有一个漏洞, 当第一次读取V的A值, 此时, 内存V的值变为B值, 然后在未执行CAS前, 又变回了A值.
此时, CAS再执行时, 会判断其正确的, 并进行赋值.

这种判断值的方式来断定内存是否被修改过, 针对某些问题, 是不适用的.

为了解决这种问题, jdk 1.5并发包提供了AtomicStampedReference(有标记的原子引用)类, 通过控制变量值的版本来保证CAS正确性.

其实, 大部分通过值的变化来CAS, 已经够用了.



jdk1.5原子包介绍(基于volatile)

包的特色:
1, 普通原子数值类型AtomicInteger, AtomicLong提供一些原子操作的加减运算.

2, 使用了解决脏数据问题的经典模式-"比对后设定", 即 查看主存中数据是否与预期提供的值一致,如果一致,才更新.

3, 使用AtomicReference可以实现对所有对象的原子引用及赋值.包括Double与Float,
但不包括对其的计算.浮点的计算,只能依靠同步关键字或Lock接口来实现了.

4, 对数组元素里的对象,符合以上特点的, 也可采用原子操作.包里提供了一些数组原子操作类
AtomicIntegerArray, AtomicLongArray等等.

5, 大幅度提升系统吞吐量及性能.

具体使用, 详解java doc.

   
最后更新时间:2007-08-06
从楼主文章链接的一些信息发现, volatile 在 1.5 以前的 JVM 中支持并不好, 好像是因为之前版本的Java规范并没有严格限定这个语义的实现.

想想也难怪, 高中时候用的 PC XT 机主存才 640KB, 老师的 286 也不过 1M, 而现在一颗 CPU 核心的内置 L2 缓存也大部分 1M 以上了, L1 缓存也有 32K. 就算主存是同步的, 这些核心的内部缓存也是个非常独立的小环境了.

不过好在 Java 的 synchronized 语义从一开始就规定得到位, 退出同步块之前要把所有线程局部存储都和主存同步. 这个虽然慢一些, 不过倒是可以保证在各个版本的 JVM 里都正确.
   
0 请登录后投票
最后更新时间:2007-08-06
是的, volatile仅仅在1.5以后的jvm中, 才得到修复.
早前使用synchrnoized的比较多!
但是测试发现, synchronized的效率没ReetranLock高.
Lock同样是同步的替代.
   
0 请登录后投票
最后更新时间:2007-08-06
volatile 保证 各线程间共享数据的一致性,和数据操作的原子性,不能保证线程间同步。
且lz文章中所指 直接从内存存取、缓存概念,并不正确。请参考lz的 多核线程笔记-java内存模型详解 中working momery model的介绍。
   
0 请登录后投票
最后更新时间:2007-08-06
Godlikeme 写道
volatile 保证 各线程间共享数据的一致性,和数据操作的原子性,不能保证线程间同步。
且lz文章中所指 直接从内存存取、缓存概念,并不正确。请参考lz的 多核线程笔记-java内存模型详解 中working momery model的介绍。


volatile修饰的变量, 将不通过cpu一二级缓存存储. (即不使用working memory)
直接通过内存, 才能保证变量的可见性.
   
0 请登录后投票
最后更新时间:2007-08-06
volatile和cpu一二级缓存和硬件实现没有关系吧,cpu一二级缓存对软件是透明的吧,cpu自己会管理,程序不可能读到不一样的。

引用
多线程中有主内存和工作内存之分, 在JVM中,有一个主内存,专门负责所有线程共享数据;而每个线程都有他自己私有的工作内存, 主内存和工作内存分贝在JVM的stack区和heap区。

volatile应该是一种软件机制。
   
0 请登录后投票
最后更新时间:2007-08-06
解释一下
java的内存模型分为main memory和working memory。注:这里的memory不是真正的内存和cpu缓存,是一个抽象概念。


main memory是实力所在的区域,所有的实例都存在于main memory中。
working memory为各个线程所拥有的工作区,所有的线程都有其专有的working memory,working memory中存有main momery中必要部分的拷贝 。

volatile保证的是所有对声明实例的操作是原子的,是直接对main memory。
具体的情况不是几句话能说清,但从模型上讲,是抽象在物理设备之上的概念。具体jvm实现上肯定要考虑物理硬件指令,有听说jdk5中增加了这部分硬件的支持。但似乎lz混淆了这层抽象概念和具体物理存储。

可以参考java language spec中,memory model和volatile field部分。
   
0 请登录后投票
最后更新时间:2007-08-07
volatile使编译器产生的汇编指令, 仅仅从主存操作数据.这点是肯定的.
working memory是否在缓存上这个问题,此前看到一些资料,获知:

多核处理器,每个处理器都会有自己的二级缓存, 那时, 如果其中一颗处理器的二级缓存里的变量被改动了, 其他的处理器是无法得知的.

只能再次通过内存交换数据.

这种情况,是否意味着工作内存使用了缓存?因为heap共享数据不会放缓存里.
而working memory确实是在java stack区中,但是,在多核环境下, 优化使用cpu cache,将是唯一提升点.
(之前说法可能有点问题)

多线程操作,通过内存本身已经是比较低性能的了.
使用volatile,还有一部分是为了防止多核处理器动态重排序执行指令.
   
0 请登录后投票
最后更新时间:2007-08-07
是啊, 现代处理器内部流水线一般都很深, 重排指令进行流水线优化是任何编译器的重要任务, C 很早就明确规定 volatile 的含义了. 可能以前Java的重心并不在性能, 所以定义了synchronized就希望它成为universal的解决方案. 但是现在Java成势了, 性能开始成为重要环节了, 也就开始启用各种复杂机制了. 感觉 NIO, java.util.concurrent, volatile 的加入都有些这种原因.

另外好像 ReentrantLock 说只有在多CPU, 并且读取线程明显多于写入线程的情况下才比 synchronized 性能有明显提高.
   
0 请登录后投票
最后更新时间:2007-08-07
重申一遍问题:
1.volatile 保证 各线程间共享数据的一致性,和数据操作的原子性,不能保证线程间同步。
2.且lz文章中所指 直接从内存存取、缓存概念,并不正确。java的内存模型分为main memory和working memory。注:这里的memory不是真正的内存和cpu缓存,是一个抽象概念。

不想把问题一捅到底,说道硬件这个层面上。在jvm这个层面上说清楚就可以了。具体的jvm实现那是实现的事情。
   
0 请登录后投票
论坛首页 Java版 企业应用

跳转论坛:
JavaEye推荐