目录
前言
多线程对共享变量的写操作容易产生线程安全问题。于是通过锁机制把多线程的并行任务变成串行化执行达到线程安全的目的。
利用试探性的思维让Synchronized锁更加智能优雅了,再也不那么粗暴了,减少阻塞,提高了程序性能,加
锁的范围实际上就是对象的生命周期范围。
1、认识Synchronized
并发编程中涉及到临界资源的抢夺,一个很重要的解决方案就是锁,想要使多线程能够完美的协调工作避免线程安全问题,让多线程执行的结果和我们期望的一个线程执行的结果一样,就必须借助一些同步机制,而锁就起到了至关重要的作用。但是任何方式方法又存在其优劣,锁也有高低效之分,所以当锁成为高并发系统的瓶颈的时候,我们就的思考对其优化了,例如本节学习的synchronized。
先从一个问题开始,看一个实例,目标:让多线程实现一个类加计数,10个线程每个线程加1000,结果是10000。
public class SynchrnoizedTest implements Runnable {
private static int num = 0;
//让num自增加一;
public void increasement() {
num++;
}
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
//创建了不同的对象,会持有不同的实例对象锁
Thread thread = new Thread(new SynchrnoizedTest());
thread.start();
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("result num: " + num);
}
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
increasement();
}
}
}
案例分析:这段代码是否能够达到我们的期望呢,结果num是10000吗?
答案是否定的,且每一次结果都不相同,这就是线程安全问题。
线程安全问题的特点:往往很难复现, 并且没有什么逻辑性,出现的问题莫名其妙。
所以在多线程对同一个共享变量进行写操作的时候,我们就需要使用一种同步机制来保证一个线程的修改结果是其他的线程是可见的,类似于数据库的悲观锁和乐观锁,最简单的理解就是,如果所有线程都通过一种有序的手段【比如排队】来获取共享变量的值就不存在取值冲突和不可见的问题了,那么这里java提供的Synchronized就派上用场了,上段代码其实只要给increasement()方法加上Synchronized就能到正确的结果了。

思考:既然需要一把锁,就要实现多个线程的互斥共享,
那这个锁是如何存储的呢? 锁的状态又如何表示呢,如何区分有锁无锁呢?
通过synchronized(lock)的语法可以知道,synchronized是基于lock这个对象的生命周期来控制力度锁的,继承于Object的任何对象都可以实现这个锁,那这个锁的存储肯定和对象有关。

2、Synchronized的使用
对象锁和类锁
不同的的加锁方式反应了锁的控制粒度,锁的范围实际上就是对象的生命周期范围。
两种生命周期和三种代码表现形式:锁类型:
-
类锁;
-
对象锁;
synchronized的三种使用方式:
-
1、修饰实例方法,作用于当前实例加锁,进入同步代码块前要获取到当前的实例锁;
-
2、修饰代码块:指定加锁对象,进入同步代码块之前要获取到给定对象的锁;
-
3、静态方法:作用于当前类锁,进入同步代码前要获取当前的类锁;
类锁的加锁方式
类锁是锁住的整个类或者某个静态的方法,所有的对象访问同步块都是同步执行的。
public class TestSynchronized2{
//方式一:类锁 直接在静态方法前加synchronized
public static synchornized void method1(){
System.out.println("This is a test");
}
//将TestSynchronized2.class作为锁对象
public static void method2(){
synchornized(TestSynchronized2.calss){
System.out.println("This is a test");
}
}
}
对象锁的两种方式
对象锁在多线程同时访问同一个对象时才是同步的,如果不同的对象访问是不同步的。
public class TestSynchronized1{
Object lock = new Object();
//方式二:方法锁(实质也是对象锁的一种,锁定的对象是this)
public synchornized void method1(){ //方法
System.out.println("This is a test");
}
//方式三:代码块 对象锁
public void method2(){
synchornized(this){ //对象锁
System.out.println("This is a test");
}
}
public void method3(){
synchornized(lock){ //对象锁
System.out.println("This is a test");
}
}
}
TestSynchronized1 test1 = new TestSynchronized1();
TestSynchronized1 test2 = new TestSynchronized1();
new Thread(()->{test1.method()}); //1
new Thread(()->{test1.method()}); //2
new Thread(()->{test2.method()}); //3
如果是对象锁,1-2存在竞争; 2-3不存在竞争;
3、Synchronized的实现
主要数据结构的实现:
-
同步队列 SynchronizedQueue:具有抢锁的资格;
-
等待队列 ConditionQueue:当已经获取到锁的线程,需要用到其他线程的数据时,主动调用wait()方法进入阻塞等待,之后等待其他线程的唤醒;
-
线程间的协作-阻塞与通知:
-
wait(); 阻塞
-
notify()/notifyAll(); 唤醒;
-
-
监视器Monitor: 任何一个对象都有一个ObjectMonitor,基于底层操作系统的MutexLock实现
-
moniteenter:获取锁
-
moniterexit: 释放锁
-
对象的组成
为了更好的了解synchronized的实现原理和结构,首先我们要了解一下对象头。hotspot采用OOP(Ordinary Object Pointer)-class模型来描述java对象实例。
java对象在内存中的布局:
-
对象头:MarkWord
-
实例数据:java 代码中能看到的属性和值
-
对齐填充字节:java对象要求是8bit的整数倍,用于字节补齐
3.1、对象头
对象头的组成:mark word:标志字段
记录了锁的状态:
-
偏向锁 01
-
轻量级锁:00
-
重量级锁:10
hashcode:每个对象都有自己的hashcode值;
存储的线程id:支持线程的重入,锁重入;
对象分代年龄:记录这对象在年轻代的gc年龄,默认是0-15之后进入老年代,用4bit表示,所以gc年龄最大为:15;
示意图如下:

从对象头的结构可以看出,对象头里指针和锁标示位记录了每个对象中锁的状态。
3.2、锁监视器monitor
加入Synchronized修饰的代码,经编译生成class文件,然后通过
javap
-v SynchronizedDemo.class反编译class文件后结果如下:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: ldc #2 // class SynchronizedDemo
2: dup
3: astore_1
4: monitorenter //monitor 获取对象的监视器
5: aload_1
6: monitorexit //monitor
7: goto 15
10: astore_2
11: aload_1
12: monitorexit //monitor 异常的时候也要释放锁
13: aload_2
14: athrow
15: invokestatic #3 // Method method:()V
18: return
Exception table:
from to target type
5 7 10 any
10 13 10 any
LineNumberTable:
line 3: 0
line 4: 5
line 5: 15
line 6: 18
StackMapTable: number_of_entries = 2
从字节码的文件可以看出,加了Synchronized的代码块前后出现了monitor指令,其实它就是对象的一个监视器,一个互斥共享的变量,也可以理解成一把锁。任何一个对象都有一个对象监视器,所有的对象都继承与Object,所以添加Synchronized修饰后,会使用监视器monitor来协调线程完成相关的工作。
任意线程对Object(Object由ynchronized保护)的访问,首先要获得Object的监视器,否则就进入阻塞等待状态,这也就实现了锁的重要特性:互斥共享。
锁的获取和释放执行过程:获取到monitor的线程继续执行业务代码;如果获取失败,则进程调用wait()进入同步队列,线程状态为BLOCKED;当访问Object的前驱获得了锁,执行完业务释放锁的同时调用notify()唤醒阻塞在同步队列的后一个线程,使其重新尝试获取监视器锁。

4、锁优化升级
4.1、锁优化目的
锁优化的目的:
并发场景下加锁保证数据访问的安全性的同时,又要保证性能,那最好就不加锁来实现同步。
-
减少加锁后线程的阻塞;
-
锁粒度的控制,一般情况下,锁粒度越细,冲突越小,程序并发度越高;
-
类似于Synchronized锁,一种试探性的算法,加锁前先根据情况进行判断,加不同的锁,显的更加优雅智能;
-
double-check通过双重检测,达到减少线程阻塞的目的;
对于临界资源的同步控制,首选无锁编程,其次考虑重量级锁Lock:
-
偏向锁:只有一个线程访问的情况,实际无锁状态;
-
轻量级锁:线程交替执行,很少竞争,实际也是无锁状态;
-
自旋锁:不阻塞通过某种方式例如cas思想实现自旋,但要注意自旋的次数问题;
-
自适应自旋锁:根据历史的经验来判断自旋的次数,尝试一段时间后,如果不OK就放弃,阻塞自己,防止无限自旋;
-
CAS:比较交换的思想很重要,可实现非阻塞;
hotspot 虚拟机的作者经过调查发现,大部分情况下,加锁的代码不仅仅不存在多线程竞争,而且总是由同一个线程 多次获得。所以基于这样一个概率,使得synchronized 在 JDK1.6 之后做了一些优化,为了减少获得锁和释放锁带来 的性能开销,引入了偏向锁、轻量级锁的概念。
通过一系列的手段优化了 synchronized 的加锁开销,使锁的性能得到了大幅度的提升。
在 synchronized的实现过程中,从对象头中就可以看出,对象中加入了无锁、
偏向锁、 轻量级锁、重量级锁的4种标识位,锁的状态根据竞争激烈的程度从低到高不断升级。
思考:锁竞争失败了该怎么办?
线程必须要做点什么?
-
等待;
-
要么继续cas;
-
要么锁升级,还没到阻塞的地步;
阻塞排队是可以解决问题,但是不是最优的,可不可以减少线程阻塞的几率而提高效率呢?因为线程的阻塞和唤醒是十分消耗性能的。例如可以让你不断的cas尝试获取,减少阻塞的成本代价。
假如有这样一个同步块代码,存在thread1 - thread2等多个线程:
Object lock = new Object();
public void acquire(){
synchronized(lock){
//doSomething()
lock.wait();
}
}
public void release(){
synchronized(lock){
//doSomething()
lock.notifyAll();
}
}
- 情况一:只有thread1进入临界区;
-
情况二:thread1和thread2交替进入临界区,竞争不激烈;
-
情况三:thread1/thread2/thread3,同时进入临界区,竞争激烈;
4.2、偏向锁
对于情况一:
偏向锁其实是一种无锁状态,在竞争不激烈的情况下,使用CAS机制直接获取线程的执行权,不用阻塞,并且是可重入锁 ;
使用场景:类似
重入锁 只有一个线程a,在没有竞争的时候把整个同步消除掉
目的:避免了线程的阻塞唤醒,线程的切换
背景前提:大部分场景线程之间不存在竞争,并且获得锁的线程大概率都是同一个;
加锁:写入线程id + 锁标记01-其实没有真正的锁;
加锁条件
(1)判断是否处于偏向状态 + 线程id为空;
(2)如果是可偏向状态:
通过cas操作,把当前线程的ID写入到markword中;
-
若cas成功,获取偏向锁成功;
-
若cas失败,撤销以获取偏向锁的线程,把锁升级为轻量级锁;
(3)
如果已是偏向状态:
检查
markword
的线程
ID
是否是自己的线程
id
;
-
如果相等,无需再次获取锁,直接重入;
-
如果不想等:说明当前锁偏向其他的线程,需要撤销升级为轻量级锁;
释放:等到竞争的时候才释放-升级为轻量级锁,并不是变为无锁状态;
-
有竞争的时候,会停止原来的进程,如果处理完了,退出了临界区,也就是代码块执行完了,新线程基于 cas 重新设置偏向;
-
如果没有就升级为轻量级锁继续执行,然后通过自旋 cas 来获取轻量级锁;
CAS的内容:记录
线程id1 + 锁标记:01:表示线程id1已经获取了偏向锁;
重入性:只有一个线程进入访问的时候,会用 CAS 比较对象头中的线程id,不需要检测,也不需要同步;

4.3、轻量级锁
对于情况二:
轻量级锁其实也是一种无锁状态,使用
自旋锁,AQS里也有实现,例如在jdk1.6之前是直接挂起阻塞;
使用场景:线程a和线程b交替访问,使用cas操作去消除同步使用的互斥量;
目的:避免了线程的阻塞唤醒,线程的切换;
背景前提:在大部分情况下,共享数据的状态只会锁定很小一段时间;
实现方案:CAS +
自旋锁[设置次数]/
自适应自旋锁[根据历史经验来判断]
(与偏向锁的最大区别)
cas的内容:对象头指向栈帧空间的一个指针;
描述:
轻量级是相对于使用操作系统互斥量来实现的传统锁而言的。轻量级锁并不是代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
加锁:在代码进入同步块的时候,如果此代码块没有被锁定,虚拟机首先在当前的线程栈帧中创建一个名为锁记录的空间,拷贝对象的对象头记录到这个锁空间,然后使用cas使对象头里的指针指向该锁空间,如果成功,则获得了该锁。升级轻量级锁的过程如下:
-
1、a线程获偏向锁开始执行临界资源;
-
2、b线程发线已经有线程在执行,
升级为轻量级锁
-
1、首先在自己的栈帧中创建锁记录LockRecord;
-
2、b线程将锁对象的对象头中的MarkWord复制到线程的刚刚创建的锁记录中;
-
3、b线程将锁记录中的owner指针指向锁对象;
-
4、b线程 自旋将锁对象的对象头的MarkWord使用cas替换为指向锁记录的指针成功;以前是null,现在是自己的地址;
释放:解锁的时候同样适用cas去解锁,将当前栈中的lockRecord替换回到锁对象中的MarkWord中;


4.4、重量级锁
对于情况三:
重量级锁才是真正意义上的加锁,使用mutexLock机制直接阻塞自己,此时线程的状态为:
blocked。
使用场景:多个线程abc同时去竞争锁,出现线程的
BLOCKED状态
实现:
JVM底层实现,基于操作系统
monitor- MutexLock互斥锁,
系统级别的一个线程切换,需要在内核态和用户态进行切换,十分损耗锁性能;
加锁:当有多个线程竞争的时候,轻量级锁就升级为重量级锁了,
wait() 阻塞其他的线程。
释放:释放的时候唤醒
notify( )/ notifyAll() 等待的线程。
描述:
升级为重量级锁过程:
-
1、当b执行的过程中, c 线程来了,c会尝试使用 自旋获取锁轻量级锁,根据历史的经验判断,仍然失败,升级为重量级锁,调用 c.wait() 阻塞自己;
-
3、b 线程 释放重量级锁,调用 c.notify() 唤醒阻塞的c线程;
5、问题思考
问题一:传统的锁有什么弊端呢?为什么效率低?
因为monitor是基于操作系统的mutexLock(互斥锁)来实现的,线程被阻塞后就进入了内核调度状态,这个对导致用户态和内核态的不间断切换,影响锁性能。
虽然通过监视器monitor实现了线程之间的同步,但如果线程获取锁失败就会进入阻塞BLOCKED状态,对于这样的线程的上下文的切换其实是很消耗资源的。
-
1、线程的一次上下文切换都会导致用户态到内核态的切换,因为java的线程实现模型是用户线程和内核线程1:1的实现方式,挂起到唤醒的过程都是系统级别的切换,需要保存现场数据到重新加载数据,竞争cpu使用权;
-
2、占中内核的栈空间;
-
3、所有该线程用到的cpu的指令和缓存都将失效;
这时候的频繁切换将会严重影响系统的性能,所以出现锁升级的优化方案,因为大多数情况可以不用阻塞线程,自旋一段时间是可以接受的,效率更高;
问题二:synchronized一定保证线程安全吗?
答:分布式和集群环境下Synchronized就失去了用武之地,所以借助于第三方出现了分布式锁,进行分布式服务临界资源的协调。
问题三:为什么wait和notify需要配合synchronized来使用?
通过jdk的源码可以发现,虽然wait()是native方法,但是注释上有一句话:“The current thread must own this object's monitor.”,所以就是使用wait()必须拥有对象的监视器monitor,我们知道监视器是synchronized底层的实现,synchronized的底层使用了monitorenter和monitorexit指令,没有获取这个对象的monitor是不能进入代码块的,所以必须配合synchronized,所以他们这对情侣必须同时出现,协同工作;
如果wait和notify不配合synchronized使用,就会抛出 java.lang.IllegalMonitorStateException 的异常。
/**
* The current thread must own this object's monitor.
* @throws IllegalMonitorStateException if the current thread is not
* the owner of the object's monitor.
* @see java.lang.Object#notifyAll()
*/
public final void wait() throws InterruptedException {
wait(0);
}
问题四:源码中的应用场景
-
mybaties的连接池中的使用;
-
Vector容器;
-
StringBuffer;
-
jdk1.7之后ConcurrentHashMap的分段锁的使用;
-
….
6、小结
Synchronized能够实现原子性,有序性和可见性;
在JDK1.6之前,只要有竞争,就会将线程阻塞Blocked等待;但JDK1.6之后,对Synchronized进行了优化,ConcurrentHashMap的分段锁就用Synchronized代替了ReentrantLock锁,所以其性能有很大的提高,如果没有特殊的需求,建议使用Synchronized锁;
优化后Synchronized显的更加智能优雅了,使用了一种试探性的思维去“分析”当前的竞争情况,根据不同的情况加不同的锁,感觉很赞,而不是上来就阻塞,显的很粗暴,不优雅。
参考资料
《并发编程的艺术》
《深入理解jvm虚拟机》
水滴石穿,积少成多。学习笔记,内容简单,用于复习,梳理巩固。