美文网首页
Java多线程的一些问题与思考

Java多线程的一些问题与思考

作者: 听歌闭麦开始自闭 | 来源:发表于2019-02-20 02:10 被阅读0次

    CPU缓存: 点击跳转
    CPU缓存与Java内存模型: 点击跳转
    Java内存模型那些事(三)理解内存屏障 : 点击跳转

    基础

    根据JMM我们有如下结论:
    每条线程都有自己的工作内存,线程的工作内存中会保存该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都在工作内存中进行,一般情况下当线程运行结束后才会将数据刷到主存中。

    JMM

    引用《深入了解Java虚拟机 第3版》中的原话
    ‘对于Sun JDK来说, 它的Windows版与Linux版都是使用一对一的线程模型实现的, 一条Java线程就映射到一条轻量级进程之中,因为Windows和Linux系统提供的线程模型就是一对一的。’

    所谓的轻量级进程就是内核线程的一种高级接口,是建立在内核之上并由内核支持的用户线程。
    线程的工作内存其实是CPU寄存器和高速缓存的抽象。
    内核线程、轻量级进程、用户线程三种线程概念解惑(线程≠轻量级进程)

    在一个线程中写入变量,另一个线程读取相同的变量,并且不通过同步对写入和读取进行顺序限制, 这就叫数据竞争(data race)。
    (对于使用volatile关键字修饰变量也是如此)

    当通过同步处理时, 就确保了不同线程对于该变量的访问顺序
    jsr133中关于先行发生关系的描述中,有如下一句:
    An unlock on a monitor happens-before every subsequent lock on that monitor.

    根据上面的结论,能得出对多线程常见问题的认知。

    关于volatile

    保证可见性

    boolean initialized = false;
    
    // 假设如下线程A的内容
    线程A {
        // ... (这里有进行一些数据初始化)
        initialized = true;
    }
    
    // 假设如下线程B的内容
    线程B {
        while(!initialized) {
            //...
        }
        // ... (这里有进行一些操作)
    }
    

    我们"原本"是想,让线程A先初始化线程B要使用的数据,初始化完成后修改信号变量,让线程B开始"正式执行"。
    但是正式执行时,逻辑可能不会按照我们预计的执行,(会出现这种情况)线程B一直在循环,因为线程B的工作内存一直是false,线程A的操作对于B不可见。
    但是当我们给initialized这个状态加上volatile时,程序正确的运行了。

    这就是volatile的保证可见性。

    那么它到底怎么实现的?
    通过查汇编代码,可以看到在写volatile时,会有一个lock addl $0x0, (%esp)操作(来自《深入了解Java虚拟机》中,但不是原话)。
    看后面的addl $0x0, (%esp)指令 (%esp是个寄存器 => 双字 这里使用addl指令操作,把esp寄存器的值加0)。
    那这lock前缀是什么意思?
    查询IA32手册,它的作用是使得本CPU的Cache写入内存同时引起别的CPU无效化其Cache (来自《深入了解Java虚拟机》)
    更多关于lock prefix的问题可以去看下IA-32架构软件开发人员手册

    在stackoverflow关于lock 前缀的解答: Can num++ be atomic for 'int num'? (票数最多的回答)
    其中提到了 MESI cache coherency protocolMemory barrier
    (经过对这2个点的搜索,就能明白了volatile的关键点,关于MESI的帖子中就有2者.)
    任何带有lock前缀的指令都有内存屏障的作用。

    So lock add dword [num], 1 is atomic. A CPU core running that instruction would keep the cache line pinned in Modified state in its private L1 cache from when the load reads data from cache until the store commits its result back into cache. This prevents any other cache in the system from having a copy of the cache line at any point from load to store, according to the rules of the MESI cache coherency protocol (or the MOESI/MESIF versions of it used by multi-core AMD/Intel CPUs, respectively). Thus, operations by other cores appear to happen either before or after, not during.
    
    (If a locked instruction operates on memory that spans two cache lines, it takes a lot more work to make sure the changes to both parts of the object stay atomic as they propagate to all observers, so no observer can see tearing. The CPU might have to lock the whole memory bus until the data hits memory. Don't misalign your atomic variables!)
    
    Note that the lock prefix also turns an instruction into a full memory barrier (like MFENCE), stopping all run-time reordering and thus giving sequential consistency. (See Jeff Preshing's excellent blog post. His other posts are all excellent, too, and clearly explain a lot of good stuff about lock-free programming, from x86 and other hardware details to C++ rules.)
    

    这就是volatile的真面目

    避免指令重排序

    在jsr133中,这样一句话,表达Java语言的语义是允许被优化的。
    The semantics of the Java programming language allow compilers and microprocessors to perform optimizations

    内存屏障可以避免指令重排序

    Java指令重排序一个有名的案例就是JDK 1.5之前使用volatile后DCL(双重检查锁定)仍然无法安全的使用。

    public class Singleton {
      private volatile static Singleton instance;
    
      private Singleton() {}
    
      public static Singleton getInstance() {
         if (instance == null) {
            synchronzied(Singleton.class) {
               if (instance == null) {
                   instance = new Singleton(); 
               }
            }
         }
         return instance;
       }
    }
    

    下面的这个说明来自双重检查锁定
    问题出在instance = new Singleton()这一句,当实例化的时候,其实分为三个步骤(自行了解对象的实例化过程)

    memory = allocate(); // 1     分配对象的内存空间
    ctorInstance(memory); // 2     初始化对象
    instance = memory; // 3    设置instance指向刚分配的内存地址
    

    上面是真实发生的实例化过程,在某些编译器上在发生指令重排序之后,可能变成下面这样

    memory = allocate(); // 1
    instance = memory; // 3
    ctorInstance(memory); // 2
    

    JDK 1.5中修复了该问题. 这就是volatile的指令重排序

    JSR113 Happens-Before

    这里来一个jsr133中关于`Happens-Before`关系的内容可以辅助理解:
    Each action in a thread happens-before every subsequent action in that thread. // thread
    An unlock on a monitor happens-before every subsequent lock on that monitor. // 阻塞同步
    A write to a volatile field happens-before every subsequent read of that volatile. // volatile
    A call to start() on a thread happens-before any actions in the started thread.
    All actions in a thread happen-before any other thread successfully returns from a join() on that thread.
    If an action a happens-before an action b, and b happens before an action c, then a happensbefore c. // 传递性
    


    锁降级

    锁降级.png

    锁降级的时候 要求当前已经获取到写锁的线程把持住写锁,然后获取读锁,随后释放写锁, 最后释放读锁。

    public class Test {
        private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
    
        private void test() {
            rw.writeLock().lock();
    
            rw.readLock().lock();
    
            // ... 一些操作
    
            rw.writeLock().unlock();
    
            rwl.readLock().unlock();
        }
    }
    

    那么如果是 获取写锁 => 释放写锁 => 获取读锁 => 释放读锁 会怎样呢?
    在线程A释放写锁到获取读锁的间隔中,因为线程争抢可能线程B获取到了写锁。
    如果线程B中有对线程A要读取的目标进行的写入操作时,根据JMM,我们可得知在线程B中对目标数据的修改,线程A可能感知不到。
    在这种情况下,程序可能会出现意想不到的问题,而遵循锁降级规则,则能避免出现该情况,这就是锁降级的必要性与其工作意义。



    线程A感知线程B对值的修改

    上面已经说过了volatile的使用,它依靠内存屏障强制刷新CPU缓存,使其它线程出现缓存未命中从而重新拉取新的缓存,这是一种能让其他线程感知到值变化的方式。

    还有一种方式可以在不使用volatile时让其它线程感知到值的变化,这里想让线程A感知到线程B对值的修改,那就让线程A在获取值前进入Safepoint。
    总结起来就是:在线程将修改的内容同步回主内存后且其他线程进入Safepoint之后会感知到值的变化。
    这种方式不好用,只是客观展示有这种情况存在。

        public static void main(String[] args) throws InterruptedException {
            Thread threadB = new Thread(() -> {
                a = 10;
            });
            Thread threadA = new Thread(() -> {
                while (true) {
                    if (a != 0) {
                        return;
                    } else {
                        // 如果去掉这里的代码会导致while循环一直执行
                        try {
                            Thread.sleep(1);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            });
    
            threadA.start();
            Thread.sleep(100);
            threadB.start();
        }
    

    相关文章

      网友评论

          本文标题:Java多线程的一些问题与思考

          本文链接:https://www.haomeiwen.com/subject/bfqqyqtx.html