文章目录
1 Q:什么是悲观锁、乐观锁
悲观锁:指在更新数据时,数据被外界修改,持保守态度。认为在更新数据的时候,大概率会有其他线程争抢共享资源。此时为了避免出错,第一个拿到资源的线程会对资源进行加排它锁, 其他没争夺到资源的线程只能进入阻塞队列 。之前上面提到的synchronized就是悲观锁的一种实现方式。
乐观锁:在更新数据时,认为数据被外界修改的概率是很小的,在更新数据时,不会对共享数据加锁,但是在正式更新数据之前会检查数据是否被其他线程修改过, 如果未被其他线程改变过就将共享变量更新成最新值,如果发现共享变量已经被其他线程更新过了,就重试,直到成功为止。典型的实现方式是CAS机制。
2 Q:那CAS机制是什么
Compare and Swap,比较并交换。如同上面提到的乐观锁,CAS是乐观锁的实现,在写回内存的时候,首先比较内存中的值与预期值是否相同,相同才会写回。
例如:假设内存中的值是x,线程中的缓存备份(预期值)A,计算后将要写回内存的值B。将B写回内存时,首先将内存中的x与预期值A比较,如果相同的话,将B赋值给x。如果不相同,认为x此时已经被其他线程修改了,重新将x赋值给A,并重新计算B,重复比较。
之前,我们提到了,使用锁会阻塞线程,会带来线程上下文的切换和重新调度开销;而volatile关键字解决了共享内存的可见性,但是不解决原子性问题。CAS的出现,解决了阻塞和非原子性的问题。
在JDK的Unsafe类提供了一系列compareAndSwap*方法,是JDK提供的非阻塞原子性操作,通过硬件保证了比较–更新操作的原子性。
例子:boolean compareAndSwapLong(Object Obj, long valueOffset, long except, long update)。
操作数分别对应:对象内存位置,对象中的变量的偏移量,变量预期值和新的值。 操作含义:如果对象Obj中的内存偏移量为valueOffset的变量值为except,则使用新值update替换旧值except。这是处理器提供的一个原子指令。
3 Q:CAS是完美的吗(ABA问题)
从辩证法角度讲,肯定不是;实际上,也有一个很经典的ABA问题。
1 当一个线程①,使用CAS操作,尝试将初始值为A的共享变量修改为B。假设在执行CAS操作前,另一个线程②使用CAS操作将A修改了B,然后又将B修改为了A,此时线程①再继续操作CAS,因为内存中和线程中都是A,所以操作成功 ,但是此时的A,已经不是线程①获得的A了。
其核心问题就在于,通过值判断版本变化,如果出现环形转换,(A->B->A),CAS操作是无感的。
在JDK中的AtomicStampedReference类,给每一个变量的状态值都配备了一个时间戳,从而避免了ABA问题。
2 在线程之间竞争程度大的时候,如果使用CAS,每次都有很多的线程在竞争,也就是说CAS机制不能更新成功。这种情况下CAS机制会一直重试,这样就会比较耗费CPU。 在并发量非常高的环境中,如果仍然想通过原子类来更新的话,可以使用AtomicLong的替代类:LongAdder。
3 Java中的CAS机制只能保证共享变量操作的原子性,而不能保证代码块的原子性。
优点:
- 可以保证变量操作的原子性;
- 并发量不是很高的情况下,使用CAS机制比使用锁机制效率更高;
- 在线程对共享资源占用时间较短的情况下,使用CAS机制效率也会较高。
4 Q:提到的Unsafe类是什么
在JDK的rt.jar包中的Unsafe类提供了硬件级别的原子性操作,Unsafe类中的方法都是native方法,它们使用JNI的方式访问本地C++实现库。
通过Unsafe.getUnsafe()
可以获取Unsafe的实例,查看源码会发现,其会校验是不是Bootstrap类加载器加载的localClass,我们自己编写的应用代码,通过main函数启动,使用的是AppClassLoader加载的,故会抛异常。
这是因为Unsafe提供了很多直接操作内存的方法,是很不安全的,所以增加了限制,不让开发人员在正规渠道开发使用,而是在rt.jar包里面的核心类中使用Unsafe功能。
但是也可以通过反射来实现,拿到相关的方法。
以下是几个主要的方法:
- long objectFieldOffset(Field field)方法:返回指定的变量在所属类中的内存偏移地址,该偏移地址仅仅在该Unsafe函数中访问指定字段时使用。
- int arrayBaseOffset(Class arrayClass)方法:获取数组中第一个元素的地址。
- int arrayIndexScale(Class arrayClass)方法:获取数组中一个元素占用的字节。
- boolean compareAndSwapLong(Object obj, long offset, long expect, long update)方法:比较对象obj中偏移量为offset的变量的值是否与expect相等,相等则使用update值更新,然后返回true,否则返回false。
- public native long getLongvolatile(Object obj, long offset)方法:获取对象obj中偏移量为offset的变量对应volatile语义的值。
- void putLongvolatile(Object obj, long offset, long value)方法:设置obj对象中offset偏移的类型为long的field的值为value,支持volatile语义。
- void putOrderedLong(Object obj, long offset, long value)方法:设置obj对象中offset偏移地址对应的long型field的值为value。这是一个有延迟的putLongvolatile方法,并且不保证值修改对其他线程立刻可见。只有在变量使用volatile修饰并且预计会被意外修改时才使用该方法。
- void park(boolean isAbsolute, long time)方法:阻塞当前线程,其中参数isAbsolute等于false且time等于0表示一直阻塞。time大于0表示等待指定的time后阻塞线程会被唤醒,这个time是个相对值,是个增量值,也就是相对当前时间累加time后当前线程就会被唤醒。如果isAbsolute等于true,并且time大于0,则表示阻塞的线程到指定的时间点后会被唤醒,这里time是个绝对时间,是将某个时间点换算为ms后的值。另外,当其他线程调用了当前阻塞线程的interrupt方法而中断了当前线程时,当前线程也会返回,而当其他线程调用了unPark方法并且把当前线程作为参数时当前线程也会返回。
- void unpark(Object thread)方法:唤醒调用park后阻塞的线程。
JDK8中新增的Long类型操作:
- long getAndSetLong(Object obj, long offset, long update)方法:获取对象obj中偏移量为offset的变量volatile语义的当前值,并设置变量volatile语义的值为update。
- long getAndAddLong(Object obj, long offset, long addValue)方法:获取对象obj中偏移量为offset的变量volatile语义的当前值,并设置变量值为原始值+addValue,原理和上面的方法类似。
5 Q:指令重排序对并发有什么影响
JAVA的内存模型允许编译器和处理器对指令重排序以提高运行性能,并且只会对不存在数据依赖性的指令重排序(体系结构上应该还是有一定优化的)。
在单线程下,指令重排序与顺序执行的结果一致,在多线程下,可能会产生不同的结果。
例如:
public static int num = 0;
public static boolean ready = false;
// 某读线程
public void run(){
if(ready){// (1)
System.out.println(num+num);// (2)
}
}
...
// 某写线程
public void run(){
num = 2;// (3)
ready = true;// (4)
}
...
// 调起线程
(3)(4)操作无依赖关系,可能被重排序。
又因为(1)(2)在另一线程,所以这将会带来多种执行顺序组合,结果也不完全一样:
(4)(3)(1)(2):4;
(4)(1)(3)(2):4;
(4)(1)(2)(3):0;
…
如上例子,这是指令重排序可能带来的问题。当然对于上述问题,根源还是共享变量,在重排序后带来的不确定。此外其实该例子还有内存可见性的问题,可以使用volatile关键字修饰ready,既可以避免内存不可见的问题,又可以避免重排序。
写volatile变量时,可以确保在写之前的操作不会被编译器重编译到写之后。
读volatile变量时,可以确保在读之后的操作,不会被编译器重排序到volatile读之前。
6 Q:什么是伪共享
学过计组的话,我们知道利用程序局部性原理,设计了cache缓存机制,且缓存内以缓冲行为单位(可能包含多个变量)。一般会对每个核心设计一个一级缓存,然后共用一个二级缓存。
在单核情况下,确实会因为局部性原理,减少对内存的访问,以提升CPU效率。
但是在多线程下,当CPU1将变量x的值修改,首先修改CPU1的一级缓存,根据缓存一致性协议,对于CPU2的一级缓存中的该缓存行也失效了。当CPU2需要访问x,就只能去二级缓存中寻找。
原因:
多线程下,并发修改一个缓存行中的多个变量时,就会竞争缓存行,从而降低程序效率。
7 Q: 如何避免伪共享
JDK8之前,一般是通过字节填充的方式,创建一个变量时,使用填充字段填充该变量所在的缓冲行。
public final static class FilledLong{
public volatile long value = 0L;
public long p1, p2, p3, p4, p5, p6;
}
如果缓存行为64Byte,将类中填充6个long类型的变量,每个占8个字节+value的8个+类对象的8个一共64字节。
但是这种方法依靠开发去根据实际编写,实用性不强。
JDK8提供了一个sun.misc.Contended注解。既可以修饰类,也可以修饰方法。
默认情况下,@Contended注解只用于java核心类,用户类路径下的类使用注解,需要添加JVM参数:-XX:-RestrictContended
填充的默认宽度为128,要自定义宽度可以设置-XX:ContendedPaddingWidth
参数。
参考文献:《java并发编程之美》