前言:上一节中所用到的Unsafed来实现递增操作,这种方式属于乐观锁,会假定能修改成功,但是假设修改的数据发现与之前的不一致,修改后就重试修改。下面主要是讲解同步关键字实现的悲观锁的原理,这种方式虽然性能上可能会慢,但是却是最容易实现线程安全的。
10.1 锁的概念
自旋锁:为了不放弃CPU执行事件,循环的使用CAS技术对数据尝试进行更新,直至成功。
自旋锁说白了就是不断循环修改直到操作成功。
悲观锁:假定会发生并发冲突,同步所有对数据的相关操作,从读数据就开始上锁。
例如上面的synchronized关键字,不管三七二十一把所有资源占住,其它线程不仅不能来写也不能来读
乐观锁:假定没有冲突,在修改数据时如果发现数据和之前获取的不一致,则读最新数据,修改后重试修改。
自旋锁也是乐观锁的一种,它假定操作能成功,如果失败就重试
独享锁(写):给资源加上写锁,线程可以修改资源,其他线程不能再加锁;(单写)
共享锁(读):给资源加上读锁后只能读不能改,其他线程也只能加读锁,不能加写锁;(多读)
可重入锁、不可重入锁:线程拿到一把锁之后,可以自由进入同一把锁所同步的其他代码。
同步关键字synchronized是可重入的,这里可能有点抽象。结合代码理解
重入锁Demo:
package szu.vander.lock;
/**
* @author : caiwj
* @date : 2019/12/7
* @description :
*/
public class ReentrantSynchronizedDemo {
private String value1;
private String value2;
public void setValue(String value) {
synchronized (this) {
this.value1 = value;
System.out.println(String.format("Thread:%s 设置value为%s成功!"
, Thread.currentThread().toString()
, this.value1));
synchronized (this) {
this.value2 = value;
System.out.println(String.format("Thread:%s 设置value为%s成功!"
, Thread.currentThread().toString()
, this.value2));
}
}
}
public static void main(String[] args) {
new ReentrantSynchronizedDemo().setValue("synchronized is Reentrant Lock");
}
}
运行结果:
效果:(如果synchronized不是可重入的,那么上述程序将会造成死循环),因为第一层synchronized嵌套套着第二层synchronized,而执行到要再次拿到当前的对象的锁的时候,第一层嵌套的代码还没有结束,所以此时监视器锁还没有被释放,但是由于是可重入的,所以又可以再次获取当前对象的监视器锁。
公平锁、非公平锁:争抢锁的顺序,如果是按先来后到,则为公平。(后一节Lock接口的时候进行说明)
JDK中几种重要的锁实现方式:synchronized、ReentrantLock、ReentrantReadWriteLock,下面一一进行讲解。
10.2 同步关键字synchronized
10.2.1 synchronized关键字的相关概念
synchronized关键字属于最基本的线程通信机制(基于线程通信可以这么理解,一个线程释放了锁,会通知在等待的线程来争抢这把锁),基于对象监视器(后面会说明)实现的。
Java中的每个对象都与一个监视器相关联,一个线程可以锁定或解锁。
一次只有一个线程可以锁定监视器,试图锁定该监视器的任何其他线程都会被阻塞,直到它们可以获得该监视器上的锁定为止。
特性:可重入、独享、悲观锁
锁的范围:类锁、对象锁、锁消除、锁粗化
**锁消除例子 **
package concurrent.lock;
/**
* @author : Vander
* @date : 2019/12/7
* @description : 锁消除例子
*/
public class LockEliminate {
public synchronized void lockEliminate() {
// jit
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append("a");
stringBuffer.append("b");
stringBuffer.append("c");
}
public static void main(String[] args){
for(int i=0; i<10000; i++) {
new LockEliminate().lockEliminate();
}
}
}
StringBuffer的append方法实际上是synchronized,但是当JIT编译器认为当前的stringBuffer作为局部变量不存在多线程竞争的情况下,会帮代码进行优化,将其中的锁消除掉,以达到更好的性能。(要想能看到锁消除的效果的话,可以用jitwatch来看)
锁粗化例子
而锁粗化也类似,即JIT编译器处于性能上的优化会将锁的访问扩大,防止频繁地加锁解锁造成大量性能的损耗。
提示:同步关键字,不仅是实现同步,根据JMM规定还能保证可见性(读取最新主内存数据,结束后写入主内存)
10.2.2 synchronized关键字的基本用法
1)锁方法
import java.time.Instant;
import java.util.concurrent.TimeUnit;
/**
* @author : Vander
* @date : 2019/12/3
* @description :
*/
public class SynchronizedDemo {
private String value;
private synchronized void readValue() throws InterruptedException {
Instant sendBefore = Instant.now();
System.out.println(sendBefore);
System.out.println("Present Value : " + value);
TimeUnit.SECONDS.sleep(3);
Instant sendAfter = Instant.now();
System.out.println(sendAfter);
}
private synchronized void setValue(String value) throws InterruptedException {
Instant sendBefore = Instant.now();
System.out.println(sendBefore);
System.out.println("Set Value : " + value);
Instant sendAfter = Instant.now();
System.out.println(sendAfter);
}
public static void main(String[] args) throws InterruptedException {
SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
new Thread(new Runnable() {
@Override
public void run() {
try {
System.out.println("thread 1 started");
synchronizedDemo.readValue();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
try {
System.out.println("thread 2 started");
synchronizedDemo.setValue("new Value");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
TimeUnit.SECONDS.sleep(5);
}
}
运行结果:
效果:说明将synchronized关键字放在方法上,默认是获取所在对象的监视器锁,所以两个线程虽然都启动了,但是线程2需要等线程1将锁释放后,线程2才能设值。
2)锁对象
package szu.vander.lock;
import java.time.Instant;
import java.util.concurrent.TimeUnit;
/**
* @author : caiwj
* @date : 2019/12/7
* @description :
*/
public class SynchronizedObjDemo {
private String value;
private String setValue(String value) throws InterruptedException {
Instant sendBefore = Instant.now();
this.value = value;
System.out.println(String.format("%s - Thread:%s 设置value为%s成功!"
, sendBefore
, Thread.currentThread().toString()
, value));
TimeUnit.SECONDS.sleep(3);
Instant sendAfter = Instant.now();
System.out.println(String.format("%s - Thread:%s 设置value为%s成功!"
, sendAfter
, Thread.currentThread().toString()
, value));
return this.value;
}
public static void main(String[] args){
SynchronizedObjDemo synchronizedObjDemo1 = new SynchronizedObjDemo();
SynchronizedObjDemo synchronizedObjDemo2 = new SynchronizedObjDemo();
new Thread(() -> {
try {
synchronized (synchronizedObjDemo1) {
synchronizedObjDemo1.setValue("I am synchronizedObjDemo1");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
new Thread(() -> {
try {
synchronized (synchronizedObjDemo2) {
synchronizedObjDemo1.setValue("I am synchronizedObjDemo2");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
}
运行结果:
从时间看来,线程2先设置了值,然后线程1才设置,并且间隔很短,但是实际上两个线程是拿到了锁的,原因是锁对象,线程2拿的是synchronizedObjDemo2的监视器锁,而线程1拿的则是synchronizedObjDemo1的监视器锁,所以对synchronizedObjDemo1的value设值都是畅通无阻的。
其实还有一种方式就是锁静态方法,因为静态方法是放在线程共享部分的,所以只要有线程用了带锁的静态方法,其它线程都要等此线程用完去抢到静态方法的锁才能用。
3)锁静态变量
package szu.vander.lock;
import szu.vander.log.Logger;
import java.util.concurrent.*;
/**
* @author : caiwj
* @date : 2020/1/25
* @description : 不使用常量作为锁对象
*/
public class SynchronizedConstantDemo {
private final static Logger log = new Logger();
private static String constStr1 = "hello world";
private static String constStr2 = "hello world";
public static void main(String[] args) throws InterruptedException {
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.submit(new Runnable() {
@Override
public void run() {
try {
test1();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
executorService.submit(new Runnable() {
@Override
public void run() {
try {
test2();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
TimeUnit.SECONDS.sleep(3);
executorService.shutdown();
}
public static void test1() throws InterruptedException {
synchronized (constStr1) {
TimeUnit.MILLISECONDS.sleep(1000);
log.info("test1 has been invoked!");
}
}
public static void test2() throws InterruptedException {
synchronized (constStr2) {
TimeUnit.MILLISECONDS.sleep(1000);
log.info("test2 has been invoked!");
}
}
}
实现效果:由于锁静态变量,静态变量位于静态区,constStr1、constStr2实际上是指向同块内存区域,所以没有起到锁两个不同变量的效果。
同步关键字使用技巧:
1)不要使用静态变量作为同步条件
2)同步的代码块越少越好
3)脏读问题,只对写方法加锁而读方法不加锁则会导致脏读
4)synchronized方法1可以调用了synchronized方法2,由于synchronized支持重入;Sub类继承Super类,Sub类实现了Super类的synchronized a(),同样也是可以重入的。
5)方法抛异常时,如果代码块不catch掉这个异常,锁就会被释放掉
6)静态方法上加synchronized实现同步,锁的是类对象,也就是跟synchronized(getClass())用的是同一把锁。
10.2.3 synchronized实现原理
在JDK官网中能找到这么一份文档:HotspotOverview.pdf(基于JDK 1.6的说法),里面有关synchronized关键字的实现
https://www.cs.princeton.edu/picasso/mats/HotspotOverview.pdf
https://wiki.openjdk.java.net/display/HotSpot/Synchronization
HotSpot虚拟机的对象(对象头部分)的内存布局开始介绍,HotSpot虚拟机的对象头(Object Header)分为两部分信息,一部分用于存储对象自身的运行时数据,如HashCode、GC分代年龄等,这部分数据的长度在32位和64位的虚拟机中分为为32bit和64bit,官方称为“Mark Word”,这是实现轻量级锁和偏向锁的关键。另一部分则用于存储指向方法去对象类型数据的指针,如果是数组对象的话,还会有一个额外的部分用于存储数组长度。
HotSpot中的对象存储
Mark Word
默认情况下JVM锁会经历:未被锁过->偏向锁->轻量级锁->重量级锁
偏向锁时 ,Bitfields一部分存放占有锁的线程的线程ID
轻量级锁时,Bitfields部分将存放Lock record address中
重量级锁时,Bitfields部分将存放Monitor address(即存放指向重量级锁的指针)
对象存储在内存中包括以上的内容。
锁的升级过程(默认是开启偏向锁的)
1)无锁->偏向锁
JVM查看锁对象,发现是没有被锁过的,获取到锁将偏向状态位设置为1,并且将线程1的Thread ID CAS到锁对象的Markword中
2)偏向锁->轻量级锁
接着线程2来尝试获取锁,JVM发现锁对象的偏向锁标识位为1,此时对象的偏向锁将升级为轻量级锁,说明一发生多线程的争抢锁就会升级为轻量级锁,JVM往线程2的栈帧中添加Lock Record标志,将当前的Markword存放在此,然后CAS锁对象的Markword,使其指向线程2栈帧的Lock Record,CAS成功的话,将线程2栈帧中的owner指向锁对象,并将锁对象的锁标识为设置为“00”。
3)轻量级锁->重量级锁
若JVM CAS锁对象的Markword失败,(它会通过自旋的方式来获取,一定次数之后,自旋都没有成功,视为失败),此时需要进行“锁升级”,将锁的标识位设置为“10”,线程2会将自己的相关信息放入该对象监视器中。监视器(管程)中有个集合存放,争抢当前对象的锁的线程,并且里面还有一个Owner属性来标志当前此对象的锁正在被哪个线程占用。(注意:每个JVM厂商设计的synchronized的实现都可能不同),但争抢锁的方式都是通过重量级锁的方式来争抢的。
偏向锁
JDK1.6引入的锁优化措施,如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做(即已经持有偏向锁的线程,执行同步块时,虚拟机不再进行任何同步操作),偏向标记第一次有用,出现过争抢后就没用了,-XX:-UseBiasedLocking禁止使用偏向锁定。
偏向锁,本质就是无锁,如果没有发生过任何多线程争抢锁的情况,JVM认为就是单线程,无需做同步(直白点,JVM为了少干活:同步在JVM底层是有许多操作来实现的,如果是没有发生争抢,就不需要进行同步操作)
偏向锁是为了让没有锁争抢的情况下,线程能够更快的获取锁,才引入了偏向锁的概念,因为如果直接用轻量级锁的话,需要在Lock record address对应的区域进行一系列的操作,但是如果用了偏向锁,就直接修改threadID就完了。
开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
关闭偏向锁:-XX:-UseBiasedLocking
重量级锁-监视器(monitor)
修改Mark Word如果失败,会自旋CAS一定次数,该次数可以通过参数配置:
超过次数,仍未抢到锁,则锁升级为重量级锁,进入阻塞。
Monitor也叫管程,计算机操作系统原理有提及类似概念。一个对象会有一个对应的Monitor。
它有多个队列,当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中。
Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中;
Entry List:Contention List中那些有资格成为候选资源的线程被移动到Entry List中;
Wait Set:哪些调用wait方法被阻塞的线程被放置在这里;
OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck;
Owner:当前已经获取到所资源的线程被称为Owner;
synchronized是不公平锁,因为它只有对进入了Waiting Queue中的线程公平,但是假设Owner释放了线程,有新的线程来CAS Owner也有可能会被新的线程所抢占,所以非公平。
synchronized关键字的原理理解是为了理解Doug Lea在JDK1.6中引入各种各样的锁。