聊聊Synchronized和Lock

目录


Synchronized和Lock的区别

Synchronized和Lock都可以用来做代码块的同步控制,也都是可重入锁,但是它俩的底层实现不一样,性能也不一样,需要根据不同的应用场景来选择合适的同步器,下面总结一下它俩的区别:

  • 来源不同
    Synchronized是Java提供的关键字,属于Java语法层面的互斥锁,也称“隐式锁”。竞争锁、释放锁的过程开发者无需关心也不能干预,由JVM来完成。
    Lock是指java.util.concurrent包下的Lock接口,描述的是一把同步锁,由Java代码来控制多线程同步,也称“显式锁”。可以自己实现一把锁,也可以直接使用由并发大神Doug Lea编写的ReentrantLock
  • 锁的释放不同
    Synchronized锁的释放由JVM来完成,开发者无法干预。同步代码块运行结束,或者出现异常JVM均会释放锁。
    Lock加的锁必须开发者手动释放,如果同步代码块抛了异常,锁没释放则会发生死锁,一般释放锁代码建议写在finally块中,确保锁一定释放。
  • 性能不同
    Synchronized在JDK6之前,采用OS级别的互斥锁,竞争锁失败的线程会被挂起,性能非常低,JDK6做了大量优化,会自动进行锁膨胀,降低了锁开销,性能提升很大,但是竞争激烈时性能还是会下降。
    Lock不管锁竞争激烈与否,性能基本保持在一个数量级,适合锁竞争比较激烈的应用场景。
  • 竞争锁失败的线程状态不同
    Synchronized竞争锁失败的线程状态是:BLOCKED
    Lock竞争锁失败的线程状态是:WAITING
  • JVM堆栈跟踪
    Synchronized阻塞的线程更加便于JVM跟踪,使用jstack可以清楚的看到。
    Lock通过LockSupport.park()来阻塞线程,不利于JVM跟踪。
  • 响应中断
    Synchronized不支持响应中断,竞争不到锁会一直阻塞。
    Lock支持响应中断。
  • 锁超时
    Synchronized不支持锁超时,竞争不到锁会一直死等,容易造成死锁。
    Lock支持锁超时,在给定时间内获取不到锁可以进行其他处理。
  • 公平/非公平锁
    Synchronized采用非公平锁,且不允许修改,可能会造成“线程饿死”。
    Lock支持公平锁与非公平锁,开发者可以自己选择。
  • 尝试获取锁判断
    Synchronized不支持获取锁成功与否的判断。
    Lock支持。
  • 读写锁
    Synchronized不支持读写锁,对于读多写少的场景无法优化性能。
    Lock支持读写锁,读读不互斥,对于读多写少的场景可以进一步优化性能。

阻塞线程状态不同

如下代码,开启两个线程去竞争锁,其中一个线程必阻塞,用jstack分别查看竞争锁失败线程的状态,分别为:BLOCKED和WAITING,这也是Synchronized和Lock的不同之处。

测试代码

public class ThreadState {
	static final ReentrantLock LOCK = new ReentrantLock();

	static synchronized void sync(){
		try {
			Thread.sleep(10000000);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}
	static void lock(){
		LOCK.lock();
		try {
			Thread.sleep(10000000);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		LOCK.unlock();
	}

	public static void main(String[] args) {
		//开启两个线程,有一个线程会竞争锁失败并阻塞
		for (int i = 0; i < 2; i++) {
			Thread thread = new Thread(() -> {
				//ThreadState.sync();
				ThreadState.lock();
			});
			thread.setName("MyThread-" + i);
			thread.start();
		}
	}
}

Synchronized:BLOCKED
在这里插入图片描述

Lock:WAITING
在这里插入图片描述

对于OS来说,线程只有三种状态:Ready、Running、Blocked。
JVM中的线程不管是BLOCKED还是WAITING,在OS看来都是Blocked阻塞态。

都是阻塞,JVM为什么还要区分不同状态呢?
为了便于JVM管理线程,虽然在OS看来都是阻塞,但是在JVM看来,BLOCKED和WAITING是两种不同的含义:一个是线程被BLOCKED、一个是线程主动WAITING

例如:当线程释放锁后,JVM只需要去BLOCKED队列唤醒一个线程,而线程调用notify()则去WAITING队列唤醒一个线程。


Synchronized实现原理

锁的是对象,而非代码

修饰实例方法时,锁的是实例对象。
修饰静态方法时,锁的是类的class对象。
修饰代码块时,锁的是给定对象。

在Java中,对象除了自身的实例数据外,还有开发者看不到的一些数据:对象头、对齐字节。如下图:
在这里插入图片描述

当线程成功竞争到锁时,会修改对象头中的Mark Word数据:偏向线程ID和锁标记。
在Java中,任何对象都有对象头信息,这意味着任何对象都可以当锁。
基本数据类型不是对象,没有对象头信息,这也就解释了:为什么基本数据类型不能作为锁对象?

monitorenter和monitorexit指令

Synchronized的实现依赖于JVM指令monitorenter和monitorexit。

如下代码:

public class MonitorDemo {
	synchronized void syncMethod(){
	}

	void method(){
		synchronized (this){
		}
	}
}

使用javac编译成class文件,再使用javap -verbose生成JVM汇编指令,如下图:
在这里插入图片描述
同步方法JVM会给其加上ACC_SYNCHRONIZED标识,当线程执行一个方法前,会先检查方法是否存在ACC_SYNCHRONIZED标识,如果存在则要去竞争对应的monitor锁,竞争锁成功再执行方法,否则线程阻塞。

同步代码块JVM则会在代码块开始前后插入monitorentermonitorexit指令,分别为竞争锁和释放锁。

monitorenter

在Java中,每个对象都会与一个monitor相关联,当某个monitor被拥有之后就会被锁住,当线程执行到monitorenter指令时,就会去尝试获得对应的monitor。

因为Synchronized锁是可以重入的,所以每个monitor都维护了一个计数器。
线程每次执行monitorenter前都会进行判断,如果当前线程拥有monitor,指令计数器就会加1,反之说明没有获得锁,线程阻塞。

monitorexit

线程每执行完一次monitorexit,计数器就减1,当计数器减至0时,monitor将会被释放,其他线程可以来竞争。


JDK6的锁优化

在JDK6之前,Synchronized是非常笨重的,以至于开发者不太愿意使用而慢慢摒弃它,但是在JDK6中,对Synchronized做了大量的优化,性能和ReentrantLock已经不相上下,官方也更加推荐使用Synchronized。

锁消除

设计一个类时,为了考虑并发安全,往往会对代码块上锁。
但是有时候压根就不会产生并发问题,例如:在线程私有的栈内存中使用线程安全的类实例,且实例不存在逃逸。

如果不存在并发安全,那还有什么理由上锁呢?
在JIT编译时,会对运行上下文进行扫描,去除不可能产生并发问题的锁。

public String method(){
	StringBuffer sb = new StringBuffer();
	sb.append("1");
	sb.append("2");
	return sb.toString();
}

如上代码,StringBuffer的append()方法被synchronized修饰,但是在该方法中不存在并发问题,方法栈内存为线程私有,sb实例不可能被其他线程访问到,对于这种情况就会进行锁消除。

锁粗化

由于锁的竞争和释放开销比较大,如果代码中对锁进行了频繁的竞争和释放,那么JVM会进行优化,将锁的范围适当扩大。

如下代码,在循环内使用synchronized,JVM锁粗化后,会将锁范围扩大到循环外面。

public String method(){
	for (int i= 0; i < 100; i++) {
		synchronized (this){
			...
		}
	}
}

自旋锁

当有多个线程在竞争同一把锁时,竞争失败的线程如何处理?

面对这种情况有两种选择:

  • 将线程挂起,锁释放后再将其唤醒。
  • 线程不挂起,自旋操作,不断的监测锁状态并竞争。

如果锁竞争非常激烈,且短时间得不到释放,那么将线程挂起效率会更高,因为竞争失败的线程不断自旋会造成CPU空转,浪费性能。

如果锁竞争并不激烈,且锁会很快得到释放,那么自旋效率会更高。因为将线程挂起和唤醒是一个开销很大的操作。

自旋锁的优化是针对“锁竞争不激烈,且会很快释放”的场景,避免了OS频繁挂起和唤醒线程。

自适应自旋锁

当线程竞争锁失败时,自旋和挂起哪一种更高效?
自适应自旋锁 解决的就是这个问题。

当线程竞争锁失败时,会自旋N次,如果仍然竞争不到锁,说明锁竞争比较激烈,继续自旋会浪费性能,JVM就会将线程挂起。

在JDK6之前,自旋的次数通过JVM参数-XX:PreBlockSpin设置,但是开发者往往不知道该设置多少比较合适,于是在JDK6中,对其进行了优化,加入了“自适应自旋锁”。

自适应自旋锁的大致原理
线程如果自旋成功了,那么下次自旋的最大次数会增加,因为JVM认为既然上次成功了,那么这一次也很大概率会成功。
反之,如果很少会自旋成功,那么下次会减少自旋的次数甚至不自旋,避免CPU空转。

锁膨胀

在JDK6之前,Synchronized用的都是重量级锁,依赖于OS的Mutex Lock来实现,OS将线程从用户态切换到核心态,成本非常高,性能很低。

在JDK6中,针对锁进行优化,不直接使用重量级锁,而是逐步进行锁的膨胀。
锁状态的级别由低到高为:无锁、偏向锁、轻量级锁、重量级锁
偏向锁、轻量级锁都属于乐观锁,重量级锁属于悲观锁。

默认为无锁状态,随着锁竞争的激烈程度会不断膨胀,最终才会使用开销最大的重量级锁。

无锁

在对象的头信息Mark Word中记录了对象的锁状态,如下图:
在这里插入图片描述
如果没有任何线程竞争锁,那么对象默认为无锁状态。

偏向锁

针对单线程锁竞争做的优化,最乐观的锁。

HotSpot作者经过研究发现,开发者为了保证线程安全问题给代码块上了锁,但是大多数情况下,锁并不存在多线程竞争,而是单线程反复获得。

单一线程,为什么还要去频繁的获取和释放锁呢?所以就有了“偏向锁”的概念。

偏向锁是针对单线程反复获得锁而做的优化,是最乐观的锁:只有单个线程来竞争锁。

在JDK5中偏向锁是关闭的,JDK6中默认开启,可以通过JVM参数-XX:-UseBiasedLocking来关闭偏向锁。

偏向锁大致流程如下:

线程A第一次获得锁后,CAS操作修改对象头信息中的Mark Word:无锁->偏向锁、偏向线程ID->线程A。
线程A需要再次获得锁时,首先判断偏向线程ID是否是自己,如果是则直接获得锁,速度非常快。
偏向锁并不会主动释放,需要等待其他线程来竞争。
线程B来竞争锁,发现锁偏向线程A,此时CAS操作失败,则进一步判断:线程A是否还在占用锁?

  • 线程A未占用:将锁重新偏向线程B,线程B获得锁。
  • 线程A仍占用:说明锁存在多线程竞争,升级为:轻量级锁。

轻量级锁

针对锁竞争不激烈做的优化,使用自旋锁避免线程频繁挂起和唤醒。

只有单一线程竞争锁时用的是偏向锁,最乐观的锁也是性能最高的锁。
一旦涉及到多线程竞争锁,就会升级为轻量级锁。

轻量级锁认为:存在多线程竞争锁,但是竞争不激烈

轻量级锁的实现原理:让竞争锁失败的线程自旋而不是挂起

如果将竞争锁失败的线程直接挂起,然后锁释放后再将其唤醒,这是一个开销很大的操作。
而大多数情况下,锁的占用时间往往非常短,会很快被释放,那么轻量级锁认为:不要挂起线程,而是让其进行自旋,执行一些无用的指令,只要锁被释放,线程马上就能获得锁,而不用等待OS将其唤醒。

线程A获得锁未释放,此时线程B来竞争锁,发现锁被线程A占用,线程B认为线程A可能很快就会释放锁,于是进行自旋操作:

  • 自旋成功:说明锁的占用时间并不长,下次会自适应增加最大自旋次数(自适应自旋)。
  • 自旋失败:锁的占用时间较长,继续自旋会浪费CPU资源,线程被挂起,升级为:重量级锁。

重量级锁

开销最大,性能最低的悲观锁,锁竞争激烈时采用。

锁竞争不激烈时,竞争锁失败的线程进行自旋而非挂起可以提升性能,因为自旋的开销>线程挂起、唤醒的开销。
但是锁竞争激烈时,自旋会造成更大的资源开销。
例如:100个线程竞争同一把锁,99个线程在自旋,意味着99%的CPU资源被浪费,此时自旋的开销>线程挂起、唤醒的开销。

当竞争比较激烈时,就会膨胀为重量级锁,因为轻量级锁的效率此时更低。

重量级锁通过监视器锁(Monitor)实现,Monitor又依赖于底层OS的Mutex Lock实现。
升级为重量级锁后,所有竞争锁失败的线程都会被阻塞挂起,锁被释放后再将线程唤醒。
线程频繁的挂起和唤醒,OS需要将线程从用户态切换为核心态,这个操作成本是非常高的,需要花费较长的时间,这就导致重量级锁效率很低。


性能比较测试

在Synchronized和Lock的区别中已经说过,在不同场景下两者的性能表现不同。

尽管JDK6为Synchronized做了大量优化,但是在竞争比较激烈时,Synchronized的性能依然有锁下降。
而Lock不管锁竞争激烈与否,性能基本保持在一个数量级,适合锁竞争比较激烈的应用场景。

分别对Synchronized和Lock进行性能测试,1、10、100线程下分别进行1亿次自增运算,采样5次。

测试代码:

/**
 * @Author: pch
 * @Date: 2020/1/28 20:29
 * @Description: 性能测试模板类
 */
public abstract class PerformanceTemplate {
	protected int threadCount = 0;//线程数
	protected int index = 0;
	protected final int count;
	protected long startTime = System.currentTimeMillis();
	private final CyclicBarrier cb;

	public PerformanceTemplate(int count, int threadCount) {
		this.count = count;
		this.threadCount = threadCount;
		this.cb = new CyclicBarrier(threadCount);
	}

	public void test() {
		int c = count / threadCount;
		for (int i = 0; i < threadCount; i++) {
			new Thread(() -> {
				try {
					cb.await();
				} catch (Exception e) {
					e.printStackTrace();
				}
				while (true) {
					func();
				}
			}).start();
		}
	}

	protected abstract void func();

	protected void print(){
		System.out.println("耗时:" + (System.currentTimeMillis() - startTime)+"ms");
		startTime = System.currentTimeMillis();
	}
}

/**
 * @Author: pch
 * @Date: 2020/1/28 15:05
 * @Description: synchronized性能测试
 */
public class Sync extends PerformanceTemplate {

	public Sync(int count, int threadCount) {
		super(count, threadCount);
	}

	@Override
	protected synchronized void func() {
		if (++index % count == 0) {
			print();
		}
	}

	@Override
	protected void print() {
		System.out.print("Synchronized:1亿次运算,"+threadCount+"线程耗时:");
		super.print();
	}
}

/**
 * @Author: pch
 * @Date: 2020/1/28 21:11
 * @Description: Lock性能测试
 */
public class Lock extends PerformanceTemplate {
	private ReentrantLock lock = new ReentrantLock();

	public Lock(int count, int threadCount) {
		super(count, threadCount);
	}

	@Override
	protected void func() {
		lock.lock();
		if (++index % count == 0) {
			print();
		}
		lock.unlock();
	}

	@Override
	protected void print() {
		System.out.print("Lock:1亿次运算,"+threadCount+"线程耗时:");
		super.print();
	}
}

测试结果:

Synchronized:1亿次运算,1线程耗时:耗时:3174ms
Synchronized:1亿次运算,1线程耗时:耗时:1878ms
Synchronized:1亿次运算,1线程耗时:耗时:2404ms
Synchronized:1亿次运算,1线程耗时:耗时:2392ms
Synchronized:1亿次运算,1线程耗时:耗时:2409ms
---
Synchronized:1亿次运算,10线程耗时:耗时:4835ms
Synchronized:1亿次运算,10线程耗时:耗时:5407ms
Synchronized:1亿次运算,10线程耗时:耗时:5391ms
Synchronized:1亿次运算,10线程耗时:耗时:5406ms
Synchronized:1亿次运算,10线程耗时:耗时:5462ms
---
Synchronized:1亿次运算,100线程耗时:耗时:4538ms
Synchronized:1亿次运算,100线程耗时:耗时:4921ms
Synchronized:1亿次运算,100线程耗时:耗时:4957ms
Synchronized:1亿次运算,100线程耗时:耗时:4999ms
Synchronized:1亿次运算,100线程耗时:耗时:4980ms
Lock:1亿次运算,1线程耗时:耗时:1985ms
Lock:1亿次运算,1线程耗时:耗时:1961ms
Lock:1亿次运算,1线程耗时:耗时:1857ms
Lock:1亿次运算,1线程耗时:耗时:2138ms
Lock:1亿次运算,1线程耗时:耗时:1912ms
---
Lock:1亿次运算,10线程耗时:耗时:2986ms
Lock:1亿次运算,10线程耗时:耗时:2861ms
Lock:1亿次运算,10线程耗时:耗时:2792ms
Lock:1亿次运算,10线程耗时:耗时:2792ms
Lock:1亿次运算,10线程耗时:耗时:2773ms
---
Lock:1亿次运算,100线程耗时:耗时:3023ms
Lock:1亿次运算,100线程耗时:耗时:2743ms
Lock:1亿次运算,100线程耗时:耗时:2706ms
Lock:1亿次运算,100线程耗时:耗时:2714ms
Lock:1亿次运算,100线程耗时:耗时:2765ms

可以看到,Synchronized经过优化之后,性能并不差,和Lock差不多,Lock性能稍微高一丢丢。


两者如何选择?

Synchronized是Java内置的同步器,使用简单,语法清晰易读,性能也不差,而且便于JVM堆栈跟踪,官方也表示Synchronized性能后期还有优化的余地,所以如果没有特殊要求,建议尽量使用Synchronized。

虽然建议尽量使用Synchronized,但是它毕竟自身存在一些功能上的缺陷,例如:无法响应中断,不支持锁超时,不能采用公平锁等等,如果确实需要这些高级特性,那么还是应该使用ReentrantLock。

两者如何选择,还是应该根据实际的业务需求来,另外并发大神Doug Lea也给出了答案:

在一些内置锁无法满足需求的情况下,ReentrantLock可以作为一种高级工具。当需要一些高级功能时才应该使用ReentrantLock,这些功能包括:可定时的,可轮询的与可中断的锁获取操作,公平队列,以及非块结构的锁。否则,还是应该优先使用Synchronized。

发布了103 篇原创文章 · 获赞 25 · 访问量 9万+

猜你喜欢

转载自blog.csdn.net/qq_32099833/article/details/104111286