long和double不具有顺序一致性
Java中的long和double无论在32位机器还是64位机器上都是8个字节,因为它需要JVM提供这种平台无关的抽象。那么,我们来看看为啥8个字节的读写操作在32位机器上不具有一致性。CPU和内存的通信是通过总线进行的,总线的宽度是固定的并且只有一条,如果有多个CPU同时请求总线进行读/写事务的话,会由总线仲裁(Bus Arbitration)进行裁决,获胜的CPU才能进行数据传递。并且如果某个CPU占用了总线,那么其它CPU要请求总线仲裁是要被拒的。这种机制就保证了CPU对内存的访问以串行的方式进行。如果有一个32位的处理器,但是要进行64位的写数据操作,这时CPU会将数据分为两个32位的写操作,并且这两个32位数据在请求总线仲裁的时候可能被分配到了不同的总线事务中,所以此时这个64位的写操作就不具有原子性了。这时如果处理器A写入了高32位,在写入低32位期间,处理器B对该数据进行了访问,那么就会产生错误的数据。(Java5之前读写都可以被拆分,Java5之后写操作可以被拆分,但是读操作必须是原子的)。如果要将变量前面加上volatile修饰符,那么对其操作将具有原子性,因为加上volatile之后,对其进行读写操作就像是“加锁”了一样。
ABA问题
乐观锁在实现的过程中利用到了CAS机制,这个机制是这样的:
有三个操作数:内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做或者使用新数值再次进行尝试。
这时就会出现一种问题:比如线程T1刚开始判断某个变量X,刚开始的数值是A,将要把它赋值为D。于此同时线程T2进来将X变为B,最后又变为A。那么线程T1在使用CAS判断的时候就会认为该数值没有变化(也就是说这个变化为B值的过程被忽略了),然后线程T1就把X的值赋成了D。在链表等场景下,这可能会引发问题。为了解决这个问题,从Java 1.5开始,JDK给我们提供了AtomicStampedReference,它的解决思路是这样的:给该类提供一个变量,每次数值变化的时候就将该变量加一,然后在利用CAS机制进行判断的时候不光判断X的值,还要判断该变量的值,这样就避免了该问题:
1 | public boolean compareAndSet(V expectedReference, |
双重锁定问题
在项目开发过程如果某个类很Expensive,我们希望创建一次,然后一直使用它,早期人们通常这样写:
1 | private static ExpensiveObj instance; |
显然这种方式非线程安全的,当线程A和线程B同时进入步骤1的时候,会发现instance没有被创建,这时它们同时创建instance。这时有人就提出了一种线程安全的写法:
1 | private static ExpensiveObj instance; |
这种实现方法是线程安全的,但是因为使用synchronized
加了锁,所以每次调用getInstance
方法,不论instance
已经被创建,都会加锁。这种频繁的加锁,解锁,会造成性能开销,因此有人提出了一种看似完美的解决方案:
1 | private static ExpensiveObj instance; |
在这个方案里,如果instance
已经被创建,那么就不需要加锁了,直接返回。如果没有创建,那么再加锁创建对象,因为这个对象只被创建一次,所以这个锁只会使用一次。这个看似完美的解决方案,其实有一个致命的缺陷:因指令重排而造成未被初始化成功的对象逸出。我们先用伪代码来看下对象的创建过程:
1 | memory = alloc(); //1: 开品内存空间 |
但是在步骤2和步骤3之间可能会发生指令重排,从而造成如下的执行顺序:
1 | memory = alloc(); //1: 开品内存空间 |
这时是如果线程A正在创建对象,同时线程B调用了getInstance
方法,那么这时instance != null
。所以就会直接使用这个对象,然而此时线程A可能没有完成对象的初始化,所以线程B使用的就是没有被完全初始化的对象,所以要想解决这个双重锁定问题只需要避免指令重排即可,我们将instance
声明为volatile
就行了:
1 | private volatile static Instance instance; |
关于指令重排,volatile关键字可以查看我的另外一篇文章:JMM。其实解决这种某个类只被创建一次的问题,也可以使用static来实现:
1 | private static class ExpensiveObjHolder { |
这里在调用getInstance
方法的时候,JVM会执行类的初始化,此时JVM回去获取一把锁,这个锁可以保证其它线程在此时不会使用该对象,其实发生了指令重拍,其它对象也不知道,因为它必须等JVM创建完成之后才可以使用。
对线程安全的类操作不一定都是线程安全的
比如AtomicInteger是线程安全的,但是下面的操作就不是线程安全了。
1 | AtomicInteger atomicInteger = new AtomicInteger(3); |
因为在线程A执行步骤1的时候,线程B也可能进入这个判断中,那么此时线程B也会再执行一次2,如果这个方法内部还有一些其它需要互斥的操作,那么就可能造成线程不安全的一系列问题。也是就是说:
线程安全操作 + 线程安全操作 ≠ 线程安全操作
增加CPU不能持续程序效率
根据Amdahl定律,增加处理器之后效率提升的最大幅度为:
串行执行代码所占百分比的倒数。
关于Amdahl定律的详细解释请看我的另外一篇文章,增加处理器的个数和资源利用率的关系如下:
从中我们可以看出,串行部分占比越多增加CPU的个数越没有用。同时CPU的利用率下降的越快,也造成了越大的费用支出。
多线程不一定快
越多的线程就可能造成越多的上下文切换,上下文切换消耗的时间在0.1毫秒到1毫秒之间,这对CPU来说已经是巨大的损耗了,频繁的上下文切换所造成的性能损耗可能不如单线程运行的程序,同时多线程可能还涉及到加锁的需要,这就操作成了更大的性能损耗(因为某些锁的底层实现是需要进行相应的系统调用)
参考资料
- https://www.cnblogs.com/549294286/p/3766717.html
- 《Java并发编程的艺术》
- 《Java并发编程实战》