引言
本文将介绍synchronized,cas等概念
1. Synchronized
1.1 锁对象是谁
加在代码块上
1 | Object o = new Object(); |
加在方法上
1 | public synchronized void fun(){ |
加在静态方法上
1 | public static synchronized void fun(){ |
只要锁是同一把即可实现同步
踩坑!!!
要注意有些时候锁住的并不是同一把锁
你以为你锁住了它,其实并不然
比如前面的加在代码块上那段,如果这样写:
1 | /** |
在多线程下每调用一次fun,锁对象o其实都new了,不是同一个,所以是起不到同步作用的,可以跑跑看,这里设置的线程数和量都比较大,不然不明显
一个是最终的结果其实是不对的
还有一个是过程可以看到其实线程之间很多时候是穿插执行的
所以正确的写法应当将锁对象o放在类中,通常将锁对象用final修饰(加不加都行,只是说一般都会加),或者在main里面传过去,不管怎样,只要保证同一把锁就行
1 | class TestSynchronized{ |
结果如下图,可以看到结果是对的,且线程之间都是间隔的,这才是真正做到了同步
再比如同时有一个synchronized静态方法,和synchronized方法,也是不同的锁,并没有锁住整个类这个说法,只是将这个类的class对象作为了锁对象!!!
参考
1.2 锁升级过程
旧版本java的synchronized是直接到重量级锁的,1.6以后是一个锁升级的过程:
没有锁->偏向锁->轻量级锁->重量级锁
偏向锁:严格来说不是锁,只有一个线程的时候,synchronized将线程标识贴在对象的markword上(markword在附录:Java对象组成中介绍了),就代表该线程持有了锁,有偏向锁的概念是因为很多时候我们用Vector,HashTable,StringBuffer的时候其实只有一个线程,如果直接锁竞争会浪费资源
轻量级锁:两个及以上线程开始锁竞争,升级成轻量级锁。说到底就是一个while循环,会消耗cpu资源,但是如果线程的任务不重,其过程是很快的
重量级锁:自旋一定次数后升级为重量级锁,进入等待队列,交由os调度,每次调度会有用户态到内核态的切换
在JDK1.6中自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整
JDK1.7后,去掉此参数,由jvm控制,在新的HotSpot VM里,使用动态调整的次数,所以叫做adaptive spinning(自适应自旋)
1.3 轻量级锁一定比重量级锁效率高吗
当然这么问了答案肯定是不一定,我们分别分析他们的开销
轻量级锁主要的开销在while会消耗cpu的资源,重量级锁主要的开销在用户态和内核态的切换
也就是说取决于任务,如果任务简单,锁竞争的线程少的情况下while循环等待的时间是很少的,如果任务时间很久或者等待的线程太多,进入任务队列采用重量级锁反而更高效,synchronized的锁升级也正是基于这个考虑来实现的
2. CAS
CompareAndSwap,比较并交换,也叫自旋锁,是乐观锁的一种实现
相比于synchronized关键字,
JUC(java.util.concurrent)包下所有类都是基于CAS的
2.1 说明
比如线程1要将变量a的值+1,那么线程1进行如下操作
读取a原值 –> +1 –> 写回前判断此时a是否还是原值 –>是则写回 –>否则循环重来
2.2 以AtomicInteger为例
AtomicInteger就是JUC下的一个类,相比于num++,它的incrementAndGet()方法可以保证原子性,于是就不用synchronized进行同步了
1 | public final int incrementAndGet() { |
跟进
1 | public final int getAndAddInt(Object var1, long var2, int var4) { |
2.3 ABA问题
就是说线程1将a的值从8变为9,写回之前线程2将8变为100,线程3又将100变回8。此时线程1不是要写回前判断嘛,判断结果还是8,他以为没有变,其实中间经历了一个变化又变回的过程。
ABA问题结果其实是正确的,主要取决于你的业务在不在乎。就像是跟女朋友分手了,然后女朋友跟别的男生好,中间经历了别的男人,现在她想和好,此时女朋友还是那个女朋友,但好像又不是那个女朋友,取决于你在不在乎罢了。
解决方式就是加版本号,
//TODO 具体是怎么加的
2.3 CAS修改值的原子性
也就是判断完还是原来的值以后,要写回,这一步要保证是原子性。比如两个线程都是将0进行++,如果这里没有保证原子性,也就是没有任何同步手段,可能两个线程判断完原值0然后++,结果为1而不是2。那这一步是如何保证原子性的呢
我们下载open jdk的源码,可以找到刚刚那个native的名字相同的对应cpp文件unsafe.cpp,找到对应方法Unsafe_CompareAndSwapInt()
继续跟进Atomic::cmpchg()
有个__asm__汇编指令,LOCK_IF_MP,继续跟进会发现最终实现为lock cmpxchg
指令。
在硬件层面,lock指令能锁定信号北桥,也就是锁总线,几乎所有cpu都支持lock指令
ps:MP是multi processor的意思,就是说多核的时候才lock,否则只执行cmpxchg,当然现在的cpu都是多核
附录
Java对象组成
markword:存储对象的HashCode,分代年龄和锁标志位信息
klass pointer:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个
类的实例
instance data:实例数据
padding:因为一定要是8kb的倍数,所以会扩展一些没有意义的内容
我们可以通过jol工具查看
1 | <dependency> |
1 | Object o = new Object(); |
结果如下
当我们加上synchronized
1 | synchronized (o){ |
其实就是锁信息,这个对象被作为锁使用,也就是所说的偏向锁的原理
缓存行Cache Line
计算机cpu和主存之间读取数据速度差距太大,所以采用了三级缓存(三层是工业实践的结果),读取进缓存的时候是每次读取“一块”,这个块实际上就是一个缓存行,现在是64字节(工业实践的结果)
缓存一致性,有了缓存行的概念之后,要明白一个问题,就是如下图,当两个数据在同一个缓存行的时候,如果cpu1(或者某个核或者某个线程)修改了x,会通知其他cpu/核/线程,来回通知是会降低效率的
其实这不是很概念很虚的东西,我们可以通过两个有趣的程序来对比感受到缓存行是真实存在的
1 | public class 缓存行示例1_同一行 { |
这段程序非常非常简单,抛开计时等不重要的代码,说到底不过是两个线程对一个T[]数组操作,T其实只有个long成员,也就是说,线程1改变T[0]的值,线程2改变T[1]的值。
结果如下
要明白,数组是申请了连续的内存,一个long占4个字节,那么这个arr包含两个long也就是连续的16个字节。
那么根据前面说的,将内存中的数据读取一个缓存行64字节进入cache,也就是说arr[0]和arr[1]都在同一个缓存行中,线程1线程2都将它们保存进自己的cache。线程1/线程2分别修改arr[0]/arr[1]的时候,需要通知对方,情况也就完全和前面那张示例图一样了。
第二个程序只需要将class T的声明改成如下,其他完全一样
1 | private static class T{ |
这样一来,x前面有7个long,后面有7个long,算上x,8*8=64个字节,也就是说arr[0]中的x和arr[1]的任何一个元素都不会在同一个缓存行
此时修改的时候不需要通知对方,因为对方的缓存行中没有保存自己修改的数据
执行结果如下:
可以看到确实快了不少
你可能会问,真的有人这样写代码吗?
你好,有的。有个单机版最快MQ叫disruptor,里面的RingBuffer类的父类就有如下代码
讲个题外:MESI只是因特尔的缓存一致性协议,不要一讲到缓存一致性协议就是MESI
主存和内存不是同一个东西
计组一些基础概念,学的时候就很疑惑,比如主存是不是我们说的内存条啊什么的,学很久了怕自己忘记,先记下来
内存是个很宽泛的概念,不同的书定义也不同,但是一般认为内存是包括cache和主存的
知乎上看见一张不错的图:
参考
知乎:乔乔-缩头者的回答https://www.zhihu.com/question/28445273