同步
volatile
线程不安全
用 javap 反编译出来的,即使只有一条字节码指令,也并不意味着这是一个原子操作。
一条字节码指令也可能转换为若干条本地机器码指令,用 -XX:+PrintAssembly 参数输出的反汇编才更严谨。
private volatile int a = 0;
public void increase() {
a ++; // 假设分为三部:int tmp = a; tmp = tmp + 1; a = tmp;
}
// 在多个线程同时操作 increase() 时,是不安全的,因为 a++ 涉及到多部操作。
private volatile int value = 0;
public void setValue(int value) {
this.value = value; // 这里就是线程安全的, setter 方法对 value 的修改不依赖于原值
}
指令重排序
volatile boolean initialized = false;
//假设以下代码在线程A中执行
信息配置操作 // 配置操作
initialized = true;
//假设以下代码在线程B中执行
while(!initialized){
sleep();
}
如:我们本来打算,在线程 A 中配置完成信息后才设置 initialized = true; 但是可能会因为指令重排,导致 initialized = true; 先执行。说明即使在单个线程中也会指令重排序。如果信息配置操作中,涉及到了多线程,那么 volatile 修饰也没什么用了。
注意:这里的单个线程的指令重排序指的是:在不影响结果的情况下才会进行。我们本来是希望信息配置操作结束后,才initialized = true;但是,JVM 觉得这两个操作没有任何关系,所以自作主张进行重排序。
重排序规则
数据依赖性:如果两个操作访问同一个对象,而且至少有一个是写操作,这两个操作就存在数据依赖性
重排序分类:
- 编译器优化:编译器在不改变单线程语义的前提下,可以重排序语句的执行顺序。
- 指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
volatile重排序规则
- 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
- 当第二个操作是volatile写时,不管第一个操作是什么(不管有没有volatile修饰),都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。(就比如信息配置的哪个例子)
- 当第一个操作是volatile读时,不管第二个操作是什么(不管有没有volatile修饰),都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
a = 3;
b;(读操作) // 假如只有 b 是volatile修饰, 那么 c,d,e 的操作不能在 b 之前。
c = 4; // 假如只有 c 是 volatile 修饰,那么对 a,b 的操作要在 c 之前。
d;(读操作)
e = 5;
DCL
DCL(双端检索)机制不是线程安全的,原因是有指令重排序的存在,加入volatile可以禁止指令重排。
原因在于某一个线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化。
instance = new SingletonDemo();可以分为以下3步骤完成(伪代码)
- memory = allocate(); //分配对象内存空间
- instance(memory); //初始化对象
- instance = memory; //设置instance指向刚分配的内存地址,此时instance != null
步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中没有改变,因此这种重排优化是允许的。
- memory = allocate(); //分配对象内存空间
- instance = memory; //设置instance指向刚分配的内存地址,此时instance != null,但是对象还没有初始化完成!
- instance(memory); //初始化对象
但是指令重排只会保证穿行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性。所以当一条线程访问instance不为null时,由于instance实例未必已初始化完成,也就造成了线程安全问题。
private static volatile VolatileDclDemo instance;
// volatile修饰后,会强制cpu和编译器按照顺序执行代码,所以就不用担心指令重排导致上面的问题了。
锁
锁能保证只有一个线程可以进入临界区,未能获取锁的线程会被阻塞。
下面这个锁属于重入锁,因为线程可以反复获取已拥有的锁。锁有一个持有计数来跟踪对lock方法的嵌套调用,被一个锁保护的方法可以调用另一个使用相同锁的方法。
var myLock = new ReentrantLock(); // 可重入锁
myLock.lock();
try {
// 如果调用了另一个使用相同锁的方法,计数加 1
} finally {
myLock.unlock();
}
// 要确保临界区的代码不要因为异常而跳出临界区,如果跳出临界区,finally将释放锁,此时对象可能就会处于破坏状态
条件对象
如果线程进入临界区后发现只有满足了某个条件之后才能执行。可以使用一个条件对象来管理那些已经获得了一个锁却不能做有用工作的线程。
// 一个锁对象可以拥有一个或多个相关联的条件对象
var myLock = new ReentrantLock();
var myCondition = myLock.newCondition();
var myCondition2 = myLock.newCondition();
var myLock = new ReentrantLock(); // 锁对象
var myCondition = myLock.newCondition();
myLock.lock();
try {
while(...) myCondition.await(); // await使当前线程*暂停并放弃锁*,进入阻塞状态,这样其他线程就可以执行了
// ...逻辑代码
myCondition.signalAll(); // 一旦某个线程从await掉回,将从之前暂停的地方继续执行(所以用while)。
} finally {
myLock.unlock();
}
// 如果所有的线程都进入await状态,程序会永远挂起。
// 等待获取锁的线程(如上面的没有使用条件对象的阻塞的线程)和已经调用了await方法的线程不同。调用了await方法,它就进入这个条件的等待集,当锁可用时,该线程不会变为可运行状态,仍旧是处于非活动状态,直到另一个线程在同一条件下调用signalAll方法。
// signalAll解除所有等待线程的阻塞,使他们竞争访问对象。
// signal则会随机选择一个,它的效率更高,但如果选中的无法满足要求就会进入死锁。
synchronized
再强调一下:锁能保证只有一个线程可以进入临界区(这里可以认为被synchronized修饰的块),未能获取锁的线程会被阻塞。
Lock 接口和 Condition 接口允许充分控制锁定,不过也可以使用较为方便的 synchronized 控制。
从 1.0 版本开始,Java 每个对象都有一个内部锁,内部锁对象只有一个关联条件(可以认为只有一个条件对象)。使用wait(); notifyAll(); notify();方法,他们都是Object里的方法。这个锁会管理试图进入 synchronized 方法的线程。
将静态方法声明为 synchronized 时,没有其他线程可以调用这个类的该方法或任何其他同步静态方法。
继承
父类方法有synchronized修饰
1、子类继承父类时,如果没有重写父类中的同步方法,子类同一对象,在不同线程并发调用该方法时,具有同步效果。
2、子类继承父类,并且重写父类中的同步方法,但没有添加关键字synchronized,子类同一对象,在不同线程并发调用该方法时,不再具有同步效果,这种情形即是网络上关键字synchronized不能被继承更详细叙述。
下面是我做的另一个测试:
threadA执行子类方法,threadB执行父类方法。父类和子类方法都有 synchronized 修饰。
结论就是:当调用子类同步方法时,不会对父类方法加锁。
// 执行结果的其中一种情况:
开始了.。。
什么时候出来 // 父类中的方法
calling doSomething
public class Widget {
public synchronized void doSomething() throws InterruptedException {
System.out.println("什么时候出来");
}
public static void main(String[] args) throws InterruptedException {
Thread threadA = new Thread(() -> {
LoggingWidget loggingWidget = new LoggingWidget();
loggingWidget.doSomething(); // 子类
});
Thread threadB = new Thread(() -> {
Widget widget = new Widget();
widget.doSomething(); // 父类
});
threadA.start(); // 子类
threadB.start();
}
}
class LoggingWidget extends Widget {
@Override
public synchronized void doSomething() throws InterruptedException {
System.out.println("开始了.。。");
int i = 0;
while (i < 100_000_000) i++;
System.out.println("calling doSomething");
}
}
同步块
使用一个对象的锁来实现额外的原子操作,称为客户端锁定。
var obj = new Object(); // 对某个对象进行锁定(因为每个对象都有一个内部锁)
synchronized(obj) {
// 一个线程可以从挂起变为可运行(唤醒),即使没有用notifyAll(),notify(),中断,等待超时,这就是虚假唤醒
while(条件不满足) { // 防止线程被虚假唤醒,就是不断地测试该线程被唤醒的条件是否满足。
obj.wait(); // 当前线程调用wait把自己挂起,就跟Condition条件不满足进入阻塞一样
}
}
比如:Vector 类
public void method(Vector<Double> vec) {
synchronized(vec) {
vec.set(...);
vec.get(...);
}
}
// 该方法是可行的,但是基于这样一个事实,同步块里的方法也使用了内部锁。
// 同步块里或者说synchronized修饰的方法具有原子性,**原子性指要么都做,要么都不做**
// **还是会发生指令重排序的**,也要小心
final
// 不安全的发布
public Holder holder;
public void initialize() { // 构造器,初始化
holder = new Holder(10);
}
// 对于创建该对象的线程,总是可以得到正确的对象。
// 对于其他线程来说,因为存在可见性问题,可能会看到 Holder 对象处于不一致的状态。
public final Holder holder;
// 对于 final 修饰的字段,在构造器中一旦被初始化完成,其他线程就能看见 final 字段的值。
// 在不可变对象(final)的内部仍可以使用可变对象(Set)来管理他们的状态
public class MyTest {
private final Set<String> stooges = new HashSet<String>(); // Set是可变的,但是用final修饰
public MyTest() { // **只有在构造器**中初始化才是具有可见性。否则还是需要同步(访问时需要同步:读、写)
stooges.add("Moe");
stooges.add("Larry"); // 是可变的
}
}
static
// 静态初始化器由JVM在类的初始化阶段完成,而在JVM内部有同步机制。所以是安全的。
public static Holder holder = new Holder(10);
CAS
Compare and Swap,它通过硬件保证了原子性。
但是CAS不能解决ABA问题,可以用JDK中的某些类来避免ABA问题。
Unsafe
Unsafe提供了硬件级别的原子性操作,它的所有方法都是 native 方法,用 C++ 实现。
集合
HashMap
https://www.jianshu.com/p/c00308c32de4
JDK1.8之前HashMap的结构为数组+链表,JDK1.8之后HashMap的结构为数组+链表+红黑树;JDK1.8之前ConcurrentHashMap的结构为segment数组+数组+链表,JDK1.8之后ConcurrentHashMap的结构为数组+链表+红黑树。
ConcurrentHashMap是线程安全的和在并发环境下不需要加额外的同步。虽然它不像Hashtable那样需要同样的同步等级(全表锁),但也有很多实际的用途。- 你可以使用
Collections.synchronizedMap(HashMap)来包装HashMap作为同步容器,这时它的作用几乎与Hashtable一样,当每次对Map做修改操作的时候都会锁住这个Map对象,而ConcurrentHashMap会基于并发的等级来划分整个Map来达到线程安全,它只会锁操作的那一段数据而不是整个Map都上锁。 ConcurrentHashMap有很好的扩展性,在多线程环境下性能方面比做了同步的HashMap要好。
ConcurrentHashMap vs Hashtable vs Synchronized Map
- 三个集合类都是线程安全的,但是他们有差别。
Hashtable是一个遗弃的类,它把所有方法都加上synchronized。所有的方法都同步这样造成多个线程访问效率特别低。Synchronized Map与HashTable差别不大,也是在并发中作类似的操作,两者的唯一区别就是Synchronized Map没被遗弃,它可以通过使用Collections.synchronizedMap()来包装Map作为同步容器使用。 - 另一方面,
ConcurrentHashMap的设计有点特别,表现在多个线程操作上。它不用做外的同步的情况下默认同时允许16个线程读和写这个Map容器。因为其内部的实现剥夺了锁,使它有很好的扩展性。不像HashTable和Synchronized Map,ConcurrentHashMap不需要锁整个Map,相反它划分了多个段(segments),要操作哪一段才上锁那段数据。
HashMap
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null) // 第 6 行
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold) // 38 行
resize();
afterNodeInsertion(evict);
return null;
}
其中第六行代码是判断是否出现hash碰撞,假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完第六行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
除此之前,还有就是代码的第38行处有个++size,我们这样想,还是线程A、B,这两个线程同时进行put操作时,假设当前HashMap的zise大小为10,当线程A执行到第38行代码时,从主内存中获得size的值为10后准备进行+1操作,但是由于时间片耗尽只好让出CPU,线程B快乐的拿到CPU还是从主内存中拿到size的值10进行+1操作,完成了put操作并将size=11写回主内存,然后线程A再次拿到CPU并继续执行(此时size的值仍为10),当执行完put操作后,还是将size=11写回内存,此时,线程A、B都执行了一次put操作,但是size的值只增加了1,所有说还是由于数据覆盖又导致了线程不安全。
ConcurrentHashMap
public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>
implements ConcurrentMap<K,V>, Serializable {}
浙公网安备 33010602011771号