先说明下, 本文要讨论的多线程读写是指一个线程写, 一个或多个线程读, 不包括多线程同时写的情况.
试想下这样一个场景: 一个线程往 hashmap 中写数据, 一个线程往 hashmap 中读数据. 这样会有问题吗? 如果有, 那是什么问题?
相信大家都知道是有问题的, 但至于到底是什么问题, 可能就不是那么显而易见了.
问题有两点.
一是内存可见性的问题, hashmap 存储数据的 table 并没有用 voliate 修饰, 也就是说读线程可能一直读不到数据的最新值.
二是指令重排序的问题, get 的时候可能得到的是一个中间状态的数据, 我们看下 put 方法的部分代码.
- final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
- boolean evict) {
- ...
- if ((p = tab[i = (n - 1) & hash]) == null)
- tab[i] = new Node<>(hash, key, value, next);
- ...
- }
可以看到, 在 put 操作时, 如果 table 数组的指定位置为 null, 会创建一个 Node 对象, 并放到 table 数组上. 但我们知道 jvm 中 tab[i] = new Node<>(hash, key, value, next); 这样的操作不是原子的, 并且可能因为指令重排序, 导致另一个线程调用 get 取 tab[i] 的时候, 拿到的是一个还没有调用完构造方法的对象, 导致不可预料的问题发生.
上述的两个问题可以说都是因为 HashMap 中的内部属性没有被 voliate 修饰导致的, 如果 HashMap 中的对象全部由 voliate 修饰, 则一个线程写, 一个线程读的情况是不会有问题 (这里是我的猜测, 证实这个猜测正确性的一点依据是 ConcurrentHashMap 的 get 并没有加锁, 也就是说在 Map 结构里读写其实是不冲突). 见下方区 sora-zero 同学的评论
创建对象的原子性问题
有的同学对于 Object obj = new Object(); 这样的操作在多线程的情况下会拿到一个未初始化的对象这点可能有疑惑, 这里也做个简单的说明. 以上 java 语句分为 4 个步骤:
在栈中分配一片空间给 obj 引用
在 jvm 堆中创建一个 Object 对象, 注意这里仅仅是分配空间, 没有调用构造方法
初始化第 2 步创建的对象, 也就是调用其构造方法
栈中的 obj 指向堆中的对象
以上步骤看起来也是没有问题的, 毕竟创建的对象要调用完构造方法后才会被引用.
但问题是 jvm 是会对指令进行重排序的, 重排之后可能是第 4 步先于第 3 步执行, 那这时候另外一个线程读到的就是没有还执行构造方法的对象, 导致未知问题. jvm 重排只保证重排前和重排后在单线程中的结果一致性.
注意 java 中引用的赋值操作一定是原子的, 比如说 a 和 b 均是对象的情况下不管是 32 位还是 64 位 jvm,a=b 操作均是原子的. 但如果 a 和 b 是 long 或者 double 原子型数据, 那在 32 位 jvm 上 a=b 不一定是原子的 (看 jvm 具体实现), 有可能是分成了两个 32 位操作. 但是对于 voliate 的 long,double 变量来说, 其赋值是原子的.
具体可以看这里
数据库中读写一致性
跳出 hashmap, 在数据库中都是要用 mvcc 机制避免加读写锁. 也就是说如果不用 mvcc, 数据库是要加读写锁的, 那为什么数据库要加读写锁呢? 原因是写操作不是原子的, 如果不加读写锁或 mvcc, 可能会读到中间状态的数据, 以 HBase 为例, Hbase 写流程分为以下几个步骤:
1. 获得行锁
2. 开启 mvcc
3. 写到内存 buffer
4. 写到 append log
5. 释放行锁
6.flush log
7.mvcc 结束 (这时才对读可见)
试想, 如果没有不走 2,7 也不加读写锁, 那在步骤 3 的时候, 其他的线程就能读到该数据. 如果说 3 之后出现了问题, 那该条数据其实是写失败的. 也就是说其他线程曾经读到过不存在的数据.
同理, 在 MySQL 中, 如果不用 mvcc 也不用读写锁, 一个事务还没 commit, 其中的数据就能被读到, 如果用读写锁, 一个事务会对中更改的数据加写锁, 这时其他读操作会阻塞, 直到事务提交, 对于性能有很大的影响, 所以大多数情况下数据库都采用 MVCC 机制实现非锁定读.
来源: http://www.bubuko.com/infodetail-3138644.html