【4】彻底理解synchronized 与 CAS

2020-4-10整理文章

简介

在学习知识前,我们先来看一个现象:

  • 示例代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    public class SynchronizedDemo implements Runnable {
    private static int count = 0;

    public static void main(String[] args) {
    for (int i = 0; i < 10; i++) {
    Thread thread = new Thread(new SynchronizedDemo());
    thread.start();
    }
    try {
    Thread.sleep(500);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    System.out.println("result: " + count);
    }

    @Override
    public void run() {
    for (int i = 0; i < 10000*100; i++)
    count++;
    }
    }
    开启了10个线程,每个线程都累加了100,0000次,如果结果正确的话自然而然总数就应该是10 * 10000*100 = 1000*10000
    可就运行多次结果都不是这个数,而且每次运行结果都不一样。这是为什么了?有什么解决方案了?这就是我们今天要聊的事情。

在之前的学习中我们知道了java内存模型的一些知识,并且已经知道出现线程安全的主要来源于JMM的设计,主要集中在主内存和线程的工作内存而导致的内存可见性问题,以及重排序导致的问题,进一步知道了happens-before规则
线程运行时拥有自己的栈空间,会在自己的栈空间运行,如果多线程间没有共享的数据也就是说多线程间并没有协作完成一件事情,那么,多线程就不能发挥优势,不能带来巨大的价值。
那么共享数据的线程安全问题怎样处理?很自然而然的想法就是每一个线程依次去读写这个共享变量,这样就不会有任何数据安全的问题,因为每个线程所操作的都是当前最新的版本数据。
那么,在java关键字synchronized就具有使每个线程依次排队操作共享变量的功能。
很显然,这种同步机制效率很低,但synchronized是其他并发容器实现的基础,对它的理解也会大大提升对并发编程的感觉,从功利的角度来说,这也是面试高频的考点。
好了,下面,就来具体说说这个关键字。

原理

在java代码中使用synchronized可是使用在代码块和方法中,根据Synchronized用的位置可以有这些使用场景:
在这里插入图片描述
如图,synchronized可以用在方法上也可以使用在代码块中,其中方法是实例方法和静态方法分别锁的是该类的实例对象和该类的对象。
而使用在代码块中也可以分为三种,具体的可以看上面的表格。
这里的需要注意的是:类对象就是这个类的Class对象。一个类的Class对象只有一个

现在我们已经知道了怎样synchronized了,看起来很简单,拥有了这个关键字就真的可以在并发编程中得心应手了吗?爱学的你,就真的不想知道synchronized底层是怎样实现了吗?

monitor机制

【对象锁(monitor)机制】
现在我们来看看synchronized的具体底层实现。先写一个简单的demo:

1
2
3
4
5
6
7
8
9
10
public class SynchronizedDemo {
public static void main(String[] args) {
synchronized (SynchronizedDemo.class) {
}
method();
}

private static void method() {
}
}

上面的代码中有一个同步代码块,锁住的是类对象,并且还有一个同步静态方法,锁住的依然是该类的类对象。
编译之后,切换到SynchronizedDemo.class的同级目录之后,然后用javap -v SynchronizedDemo.class查看字节码文件:
在这里插入图片描述
如图,上面用黄色高亮的部分就是需要注意的部分了,这也是添Synchronized关键字之后独有的。
执行同步代码块后首先要先执行monitorenter指令,退出的时候monitorexit指令。
通过分析之后可以看出,使用Synchronized进行同步,其关键就是必须要对对象的监视器monitor进行获取,当线程获取monitor后才能继续往下执行,否则就只能等待。
而这个获取的过程是互斥的,即同一时刻只有一个线程能够获取到monitor。

上面的demo中在执行完同步代码块之后紧接着再会去执行一个静态同步方法,而这个方法锁的对象依然就这个类对象,那么这个正在执行的线程还需要获取该锁吗?
答案是不必的,从上图中就可以看出来,执行静态同步方法的时候就只有一条monitorexit指令,并没有monitorenter获取锁的指令。
这就是锁的重入性,即在同一线程中,线程不需要再次获取同一把锁。
Synchronized先天具有重入性。每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一。

任意一个对象都拥有自己的监视器,当这个对象由同步块或者这个对象的同步方法调用时,执行方法的线程必须先获取该对象的监视器才能进入同步块和同步方法,
如果没有获取到监视器的线程将会被阻塞在同步块和同步方法的入口处,进入到BLOCKED阻塞状态(关于之前我们讲到的线程状态)

下图表现了对象,对象监视器,同步队列以及执行线程状态之间的关系:
在这里插入图片描述
该图可以看出,任意线程对Object的访问,首先要获得Object的监视器,如果获取失败,该线程就进入同步状态,线程状态变为BLOCKED阻塞状态,
当Object的监视器占有者释放后,在同步队列中得线程就会有机会重新获取该监视器。

happens-before关系

在之前我们讲到happens-before规则,抱着学以致用的原则我们现在来看一看Synchronized的happens-before规则
监视器锁规则:对同一个监视器的解锁,happens-before于对该监视器的加锁。

  • 示例代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class MonitorDemo {
    private int a = 0;

    public synchronized void writer() { // 1
    a++; // 2
    } // 3

    public synchronized void reader() { // 4
    int i = a; // 5
    } // 6
    }
    该代码的happens-before关系如图所示:
    在这里插入图片描述
    在图中每一个箭头连接的两个节点就代表之间的happens-before关系
    黑色的是通过程序顺序规则推导出来
    红色的为监视器锁规则推导而出:线程A释放锁happens-before线程B加锁
    蓝色的则是通过传递性规则进一步推导的happens-before关系

现在我们来重点关注2 happens-before 5,通过这个关系我们可以得出什么?
【根据happens-before的定义中的一条:如果A happens-before B,则A的执行结果对B可见,并且A的执行顺序先于B。
线程A先对共享变量A进行加一,由2 happens-before 5关系可知线程A的执行结果对线程B可见即线程B所读取到的a的值为1。】

内存语义

我们先来看看基于java内存抽象模型的Synchronized的内存语义。
在这里插入图片描述
从上图可以看出,线程A会首先先从主内存中读取共享变量a=0的值然后将该变量拷贝到自己的本地内存,进行加一操作后,再将该值刷新到主内存,
整个过程即为线程A 加锁–>执行临界区代码–>释放锁相对应的内存语义。
在这里插入图片描述
线程B获取锁的时候同样会从主内存中共享变量a的值,这个时候就是最新的值1,然后将该值拷贝到线程B的工作内存中去,释放锁的时候同样会重写到主内存中。

从整体上来看,线程A的执行结果(a=1)对线程B是可见的,
实现原理为:
释放锁的时候会将值刷新到主内存中,其他线程获取锁时会强制从主内存中获取最新的值。
即:先获得锁 → 清空工作内存 → 在主内存中拷贝最新变量的副本到工作内存 → 执行完代码 → 将更改后的共享变量的值刷新到主内存中 → 释放锁。
另外也验证了2 happens-before 5,2的执行结果对5是可见的。

从另外一个角度来看,这就像线程A通过主内存中的共享变量和线程B进行通信,A 告诉 B 我们俩的共享数据现在为1啦,
这种线程间的通信机制正好吻合java的内存模型正好是共享内存的并发模型结构。

优化

通过上面的讨论现在我们对Synchronized应该有所印象了,它最大的特征就是在同一时刻只有一个线程能够获得对象的监视器(monitor),从而进入到同步代码块或者同步方法之中,即表现为互斥性(排它性)
这种方式肯定效率低下,每次只能通过一个线程,既然每次只能通过一个,这种形式不能改变的话,那么我们能不能让每次通过的速度变快一点了。
打个比方,去收银台付款,之前的方式是,大家都去排队,然后去纸币付款收银员找零,有的时候付款的时候在包里拿出钱包再去拿出钱,这个过程是比较耗时的,然后,支付宝解放了大家去钱包找钱的过程,现在只需要扫描下就可以完成付款了,也省去了收银员跟你找零的时间的了。
同样是需要排队,但整个付款的时间大大缩短,是不是整体的效率变高速率变快了?这种优化方式同样可以引申到锁优化上,缩短获取锁的时间

在聊到锁的优化也就是锁的几种状态前,有两个知识点需要先关注:(1)CAS操作 (2)Java对象头,这是理解下面知识的前提条件。

CAS

CAS简介

加锁是一种悲观的策略,它总是认为每次访问共享资源的时候,总会发生冲突,所以宁愿牺牲性能(时间)来保证数据安全。
无锁是一种乐观的策略,它假设线程访问共享资源不会发生冲突,所以不需要加锁,因此线程将不断执行,不需要停止。一旦碰到冲突,就重试当前操作直到没有冲突为止。

CAS(Compare And Swap比较交换)就是无锁策略来鉴别线程冲突,一旦检测到冲突产生,就重试当前操作直到没有冲突为止。
CAS的实现需要硬件指令集的支撑,在JDK1.5后虚拟机才可以使用处理器提供的CMPXCHG指令实现。【没别的线程在改变这个对象,那么这个线程就可以改了】

悲观锁&乐观锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
简介
悲观锁(Pessimistic Lock)
每次获取数据的时候,都会担心数据被修改,所以每次获取数据的时候都会进行加锁,
确保在自己使用的过程中数据不会被别人修改,使用完成后进行数据解锁。
由于数据进行加锁,期间对该数据进行读写的其他线程都会进行等待。
乐观锁(Optimistic Lock)
每次获取数据的时候,都不会担心数据被修改,所以每次获取数据的时候都不会进行加锁,
但是在更新数据的时候需要判断该数据是否被别人修改过。如果数据被其他线程修改,则不进行数据更新,
如果数据没有被其他线程修改,则进行数据更新。由于数据没有进行加锁,期间该数据可以被其他线程进行读写操作。

适用场景
悲观锁:比较适合写入操作比较频繁的场景,如果出现大量的读取操作,
每次读取的时候都会进行加锁,这样会增加大量的锁的开销,降低了系统的吞吐量。
乐观锁:比较适合读取操作比较频繁的场景,如果出现大量的写入操作,数据发生冲突的可能性就会增大,
为了保证数据的一致性,应用层需要不断的重新获取数据,这样会增加大量的查询操作,降低了系统的吞吐量。

总结
两种所各有优缺点,读取频繁使用乐观锁,写入频繁使用悲观锁。

CAS如何鉴别冲突

CAS核心算法:

1
2
3
4
5
6
7
执行函数:CAS(V,E,N)
V 要改变的变量
E expect期望的值
N new 新值
执行逻辑:
如果共享变量V等于E的话,也就是没有其他线程改变这个共享变量,那么就把这个共享变量V设置成N。
如果把这个CAS操作放在循环中,失败后再重试,直到更新成功为止。

CAS的效果

如果多个线程同时使用CAS操作一个变量的时候,只有一个线程能够修改成功。其余的线程提供的期望值已经与共享变量的值不一样了,所以均会失败。

由于CAS操作属于乐观派,它总是认为自己能够操作成功,所以操作失败的线程将会再次发起操作,而不是被OS(Operating System 操作系统)挂起。
所以说,即使CAS操作没有使用同步锁,其它线程也能够知道对共享变量的影响。

因为其它线程没有被挂起,并且将会再次发起修改尝试,所以无锁操作即CAS操作天生免疫死锁
另外一点需要知道的是,CAS是系统原语,CAS操作是一条CPU的原子指令,所以不会有线程安全问题
JDK1.5后提供的CMPXCHG指令实现。【没别的线程在改变这个对象,那么这个线程就可以改了】

atomic包

Java提供了一个Unsafe类,其内部方法操作可以像指针一样直接操作内存,方法都是native的。

为了让Java程序员能够受益于CAS等CPU指令,JDK并发包中有一个atomic包,它们是原子操作类,它们使用的是无锁的CAS操作,并且统统线程安全。
atomic包下的几乎所有的类都使用了这个Unsafe类。

  • atomic包
    在这里插入图片描述
  • 分类如下
    在这里插入图片描述

这些类中,最有代表性的就是AtomicInteger类。

AtomicInteger类源码,省略了部分代码
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
40
41
42
43
44
45
46
47
public class AtomicInteger extends Number implements java.io.Serializable {
private static final long serialVersionUID = 6214790243416807050L;

// 这个就是封装CAS操作的指针
private static final Unsafe unsafe = Unsafe.getUnsafe();

//原来内部的共享变量,就是这个value,并且使用volatile让其在多个线程之间可见
private volatile int value;

//初始化的构造函数
public AtomicInteger(int initialValue) {
value = initialValue;
}

//获取当前值
public final int get() {
return value;
}

//设置当前的共享变量的值
public final void set(int newValue) {
value = newValue;
}

//使用CAS操作设置新的值,并且返回旧的值
public final int getAndSet(int newValue) {
//使用指针unsafe类的三大原子操作方法之一
return unsafe.getAndSetInt(this, valueOffset, newValue);
}


//把expect与内部的value进行比较,如果相等,那么把value的值设置为update的值
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

//返回value,并把value + 1
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}

//自增,并且返回自增后的值
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

}

查看指针类unsafe类的incrementAndGet方法的代码实现,颇具教学意义。
这个方法是一个死循环,不断尝试获取最新的值,也就不断获取 CAS(V,E,N)中的E,也就是我们要提供的期望的值。
如果此时 共享变量V 与 我们的 E 相同,那么就把 V 的值 修改成 N。
下面代码中,先不断尝试获取最新的共享变量的值V,如果其它线程也在同时获取V,并且其它线程抢先将共享变量V 修改成了 V+1,
那么此时,当前线程持有的共享变量的值是V,它去与实际的共享变量值V+1比较,将会比较失败,所以本次自增失败。
但是因为是一个死循环,当前线程将会重新调用 get()方法获取最新的值,直到在其它线程执行CAS操作之前,抢先执行自增共享变量的操作

1
2
3
4
5
6
7
8
9
public final int incrementAndGet(){
for(;;){
int current = get();
int next = current + 1;
if(compareAndSet(current,next)){
return next;
}
}
}

ABA问题

在CAS的核心算法中,通过死循环不断获取最新的E。
如果在此之间,V被修改了两次,但是最终值还是修改成了旧值V,这个时候,就不好判断这个共享变量是否已经被修改过。
为了防止这种不当写入导致的不确定问题,原子操作类提供了一个带有时间戳的原子操作类

带有时间戳的原子操作类AtomicStampedReference (音:a tommy k,S dan P de,Reference)

CAS(V,E,N)

当带有时间戳的原子操作类AtomicStampedReference对应的数值被修改时,除了更新数据本身外,还必须要更新时间戳
当AtomicStampedReference设置对象值时,对象值以及时间戳都必须满足期望值,写入才会成功
因此,即使对象值被反复读写,写回原值,只要时间戳发生变化,就能防止不恰当的写入。

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
//2020-06-23 15:23 AtomicReference演示CAS的ABA问题
public static void main(String[] args) throws Exception {
AtomicReference<Integer> atomicReference = new AtomicReference<>();
new Thread(() -> {
System.out.println(atomicReference.compareAndSet(null, 1));// true
}, "A").start();
new Thread(() -> {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(atomicReference.compareAndSet(1, null));// true
}, "B").start();
new Thread(() -> {
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(atomicReference.compareAndSet(null, 2));// true
}, "C").start();

TimeUnit.SECONDS.sleep(3);
System.out.println(atomicReference.get());// 2
}

//2020-06-23 15:46 AtomicStampedReference的使用,加版本号,避免ABA问题
public static void main(String[] args) throws Exception {
AtomicStampedReference<Integer> atomicStampedReference = new AtomicStampedReference<>(null, 1);
new Thread(() -> {
int stamp = atomicStampedReference.getStamp();// 1
System.out.println(atomicStampedReference.compareAndSet(null, 1, stamp, ++stamp));// true
}, "A").start();
new Thread(() -> {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
int stamp = atomicStampedReference.getStamp();// 2
System.out.println(atomicStampedReference.compareAndSet(1, null, stamp, ++stamp));// true
}, "B").start();
new Thread(() -> {
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
int stamp = atomicStampedReference.getStamp();// 3
System.out.println(atomicStampedReference.compareAndSet(null, 2, 1, 4));// false
}, "C").start();

TimeUnit.SECONDS.sleep(3);
System.out.println(atomicStampedReference.getStamp());// 3
System.out.println(atomicStampedReference.getReference());// null
}

Lock和Synchronized

1、Synchronized 内置的Java关键字, Lock 是一个Java类
2、Synchronized 无法判断获取锁的状态,Lock 可以判断是否获取到了锁
3、Synchronized 会自动释放锁,lock 必须要手动释放锁!如果不释放锁,死锁
4、Synchronized 线程 1(获得锁,阻塞)、线程2(等待,傻傻的等);Lock锁就不一定会等待下去;
5、Synchronized 可重入锁,不可以中断的,非公平;Lock ,可重入锁,可以 判断锁,非公平(可以自己设置);
6、Synchronized 适合锁少量的代码同步问题,Lock 适合锁大量的同步代码!

CAS和Synchronized

两者主要的区别。
元老级的Synchronized(未优化前)最主要的问题是:在存在线程竞争的情况下会出现线程阻塞和唤醒锁带来的性能问题,因为这是一种互斥同步(阻塞同步)
而CAS并不是直接将线程挂起,当CAS操作失败后会进行一定的尝试,而非进行耗时的挂起唤醒的操作,因此也叫做非阻塞同步

CAS的应用场景

在J.U.C包中利用CAS实现类有很多,可以说是支撑起整个J.U.C包的实现,在Lock实现中会有CAS改变state变量,在atomic包中的实现类也几乎都是用CAS实现,
关于这些具体的实现场景在之后会详细聊聊,现在有个印象就好了。

CAS的问题

  1. ABA问题
    因为CAS会检查旧值有没有变化,这里存在这样一个有意思的问题。
    比如一个旧值A变为了成B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化。
    解决方案可以沿袭数据库中常用的乐观锁方式,添加一个版本号可以解决。原来的变化路径A->B->A就变成了1A->2B->3C。
    java这么优秀的语言,当然在java 1.5后的atomic包中提供了AtomicStampedReference来解决ABA问题,解决思路就是这样的。参考上面提到的ABA问题

  2. 自旋时间过长
    使用CAS时非阻塞同步,也就是说不会将线程挂起,会自旋(无非就是一个死循环)进行下一次尝试,如果这里自旋时间过长对性能是很大的消耗。
    如果JVM能支持处理器提供的pause(暂停)指令,那么在效率上会有一定的提升。

  3. 只能保证一个共享变量的原子操作
    当对一个共享变量执行操作时CAS能保证其原子性,如果对多个共享变量进行操作,CAS就不能保证其原子性。
    有一个解决方案是利用对象整合多个共享变量,即一个类中的成员变量就是这几个共享变量。然后将这个对象做CAS操作就可以保证其原子性。
    atomic中提供了AtomicReference来保证引用对象之间的原子性。

Java对象头

在同步的时候是获取对象的monitor,即获取到对象的锁。
那么对象的锁怎么理解?无非就是类似对对象的一个标志,那么这个标志就是存放在Java对象的对象头
Java对象头里的Mark Word里默认的存放的对象的Hashcode,分代年龄和锁标记位。

32位JVM Mark Word默认存储结构为
在这里插入图片描述
如图在Mark Word会默认存放hasdcode,年龄值以及锁标志位等信息。

Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态 偏向锁状态 轻量级锁状态 重量级锁状态
这几个状态会随着竞争情况逐渐升级,锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。
这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
对象的MarkWord变化为下图:
在这里插入图片描述

偏向锁

大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
偏向锁(01):一段同步代码只能被一个线程访问,不存在竞争.

偏向锁的获取

当一个线程访问同步块并获取锁时,会在对象头栈帧中的锁记录里存储锁偏向的线程ID,
以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。
如果测试成功,表示线程已经获得了锁。
如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程

偏向锁的撤销

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
在这里插入图片描述
如图,偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。
它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,
如果线程不处于活动状态,则将对象头设置成无锁状态;
如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

下图线程1展示了偏向锁获取的过程,线程2展示了偏向锁撤销的过程。
在这里插入图片描述

如何关闭偏向锁

偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。
如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态

轻量级锁

轻量级锁(00):当锁是偏向锁的时候,被其他线程访问了,这时偏向锁就升级为轻量级锁.其他线程会不断自旋获取锁,不会阻塞,从而提高性能.

加锁

线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word
然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针
如果成功,当前线程获得锁,
如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

解锁

轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,
如果成功,则表示没有竞争发生。
如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

下图是两个线程同时争夺锁,导致锁膨胀的流程图。
在这里插入图片描述
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。
当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

重量级锁

重量级锁(10):当只有一个等待线程,则该线程自旋等待.但自旋到一定的次数,或一个等待,一个持有锁,当第三个过来时,轻量就会变为重量级锁.一个获取锁,其他线程进行阻塞,而不是自旋等待.

各种锁的比较

在这里插入图片描述

锁的粗化

  • 示例代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    public class VolatileDemo {
    private static boolean isOver = false;

    public static void main(String[] args) {
    Thread thread = new Thread(new Runnable() {
    @Override
    public void run() {
    System.out.println("测试1---"+isOver);
    while (!isOver)
    System.out.println(111);
    System.out.println("测试2---"+isOver);
    }
    });
    thread.start();
    try {
    Thread.sleep(1000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    isOver = true;
    }
    }
  • 执行结果
    立即打印测试1---false,然后一直循环打印111,循环1秒后打印测试2---true

  • 分析
    System.out.println源码如下

    1
    2
    3
    4
    5
    6
    public void println(String x) {
    synchronized (this) {
    print(x);
    newLine();
    }
    }

    可以发现是println方法是加了synchronized的
    jvm中对锁的优化有一条为锁的粗化

  • 如果一系列的连续操作都对同一个对象反复加锁和解锁,
    甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
    如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(膨胀)到整个操作序列的外部(由多次加锁编程只加锁一次)。

    1
    2
    3
    4
    5
    synchronized{
    while(!isOver){
    sout();
    }
    }

    所以synchronized会将isOver 的值在锁释放前刷回共享内存(共享内存也称为系统内存,也称为主内存)

修改示例

经过上面的理解,我们现在应该知道了该怎样解决了。更正后的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class SynchronizedDemo implements Runnable {
private static int count = 0;

public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(new SynchronizedDemo());
thread.start();
}
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("result: " + count);
}

@Override
public void run() {
synchronized (SynchronizedDemo.class) {
for (int i = 0; i < 10000*100; i++)
count++;
}
}
}

开启十个线程,每个线程在原值上累加100,0000次,最终正确的结果为10 * 10000*100 = 1000*10000
这里能够计算出正确的结果是因为在做累加操作时使用了同步代码块,这样就能保证每个线程所获得共享变量的值都是当前最新的值,
如果不使用同步的话,就可能会出现A线程累加后,而B线程做累加操作有可能是使用原来的就值,即“脏值”。这样,就导致最终的计算结果不是正确的。
而使用Syncnized就可能保证内存可见性,保证每个线程都是操作的最新值。