锁和同步

锁和同步

引言

本文将介绍synchronized,cas等概念


1. Synchronized

1.1 锁对象是谁

加在代码块上

1
2
3
4
Object o = new Object();
synchronized(o){
//对象o就是这把锁
}

加在方法上

1
2
3
4
public synchronized void fun(){
//本类的对象就是这把锁
//等同于synchronized(this)
}

加在静态方法上

1
2
3
4
public static synchronized void fun(){
//本类的Class类对象
//等同于synchronized(这个类.class)
}

只要锁是同一把即可实现同步


踩坑!!!

要注意有些时候锁住的并不是同一把锁

你以为你锁住了它,其实并不然

比如前面的加在代码块上那段,如果这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* @author wjw
* @date 2020/11/21 14:56
*/
public class Synchronized锁对象踩坑说明 {

public static void main(String[] args) {
TestSynchronized test = new TestSynchronized();

for (int i = 0; i < 1000; i++) {
new Thread(()->{
test.fun();
}).start();
}
}
}

class TestSynchronized{
private int num = 0;

public void fun() {
Object o = new Object(); //注意这里是在方法里面
synchronized(o){
for (int i = 0; i < 1000; i++) {
System.out.println(Thread.currentThread().getName() + " : " + num++);
}
}
}
}

在多线程下每调用一次fun,锁对象o其实都new了,不是同一个,所以是起不到同步作用的,可以跑跑看,这里设置的线程数和量都比较大,不然不明显

一个是最终的结果其实是不对的

还有一个是过程可以看到其实线程之间很多时候是穿插执行的

所以正确的写法应当将锁对象o放在类中,通常将锁对象用final修饰(加不加都行,只是说一般都会加),或者在main里面传过去,不管怎样,只要保证同一把锁就行

1
2
3
4
5
6
7
8
9
10
11
12
13
class TestSynchronized{

private final Object o = new Object(); //将锁对象放在类上
private int num = 0;

public void fun() {
synchronized(o){
for (int i = 0; i < 1000; i++) {
System.out.println(Thread.currentThread().getName() + " : " + num++);
}
}
}
}

结果如下图,可以看到结果是对的,且线程之间都是间隔的,这才是真正做到了同步

再比如同时有一个synchronized静态方法,和synchronized方法,也是不同的锁,并没有锁住整个类这个说法,只是将这个类的class对象作为了锁对象!!!

参考

https://www.cnblogs.com/codebj/p/10994748.html


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(自适应自旋)

https://www.zhihu.com/question/31187779


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
2
3
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

跟进

1
2
3
4
5
6
7
8
9
10
11
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

return var5;
}

//再跟进就看到是native方法了
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

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
2
3
4
5
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
1
2
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());

结果如下

为了避免关键字所以名字是klass pointer,图片里面写错了

当我们加上synchronized

1
2
3
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}

其实就是锁信息,这个对象被作为锁使用,也就是所说的偏向锁的原理


缓存行Cache Line

计算机cpu和主存之间读取数据速度差距太大,所以采用了三级缓存(三层是工业实践的结果),读取进缓存的时候是每次读取“一块”,这个块实际上就是一个缓存行,现在是64字节(工业实践的结果)

缓存一致性,有了缓存行的概念之后,要明白一个问题,就是如下图,当两个数据在同一个缓存行的时候,如果cpu1(或者某个核或者某个线程)修改了x,会通知其他cpu/核/线程,来回通知是会降低效率的

其实这不是很概念很虚的东西,我们可以通过两个有趣的程序来对比感受到缓存行是真实存在的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class 缓存行示例1_同一行 {

public static long COUNT = 1_0000_0000L;

private static class T{
public volatile long x = 0L;
}

public static T[] arr = new T[2];

static {
arr[0] = new T();
arr[1] = new T();
}

public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(2);

Thread t1 = new Thread(()->{
for (int i = 0; i < COUNT; i++) {
arr[0].x = i;
}
latch.countDown();
});

Thread t2 = new Thread(()->{
for (int i = 0; i < COUNT; i++) {
arr[1].x = i;
}
latch.countDown();
});

final long start = System.nanoTime();
t1.start();
t2.start();
latch.await();
System.out.println((System.nanoTime() - start) / 100_0000);
}
}

这段程序非常非常简单,抛开计时等不重要的代码,说到底不过是两个线程对一个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
2
3
4
5
private static class T{
public long p1, p2, p3, p4, p5, p6, p7; //前面7个
public volatile long x = 0L;
public long p9, p10, p11, p12, p13, p14, p15; //后面7个
}

这样一来,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

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×