Java锁机制之synchronized

2022/1/8 22:33:38

本文主要是介绍Java锁机制之synchronized,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

java中2种锁的实现原理区别:

synchronized: 在软件层面依赖JVM,在jvm将class文件编译成字节码文件时添加monitorenter和monitorexit句柄来区分加锁代码块
Lock: 在硬件层面依赖特殊的CPU指令。

synchronized机制:

首先需要明确的一点是:Java多线程的锁都是基于对象的,Java中的每一个非空对象都可以作为一个锁。

synchronized关键字锁具体表现为:锁对象和锁对象的class类;每个类可以有很多实例对象,不同实例对象的对象锁互不干扰,但每个类只有一个class对象,所有类只有一把公用锁。具体的代码表现为:

// 锁对象为对象实例(this)
public synchronized void method(){
	// todo
}

// 锁对象为对象对应的Class实例,因为Class数据存在于永久代,因此静态方法锁相当于该类的一个全局锁
public static synchronized void method1(){
	// todo
}

// synchronized句柄参数中的对象实例(lock)
Object lock = new Object();
public void method2(){
	synchronized(lock){
		// todo	
	}
}

我们这里介绍一下 “临界区” 的概念。所谓“临界区”,指的是某一块代码区域,它同一时刻只能由一个线程执行。在上面的例子中,如果synchronized关键字在方法上,那临界区就是整个方法内部。而如果是使用synchronized代码块,那临界区就指的是代码块内部的区域。

TIPS:

  1. 如果synchronized作用于对象,如果对象不同,持有锁也会不同;
  2. 作用于类,虽然对象不同,但是类对象只有一个,该类所有的对象持有同一把锁。
  3. synchronized关键字不能被继承。
  4. 如果一个类中同时包含对象锁和类锁,类锁和对象锁是两个不一样的锁,控制着不同的区域,它们是互不干扰的。
  5. synchronized的代价较高,使用不当容易产生死锁,慎用。

在HotSpot JVM实现中,锁有个专门的名字:对象监视器。

synchronized是可重入锁:

所谓的可重入锁即已经获取锁的线程可以多次锁定 /解锁监视对象,也就是一个线程已经获取到某个锁可以重复获取该锁。
重入锁的实现方法是为每个锁关联一个线程持有者和计数器,当锁对象未被任何线程持有时计数器为0,此时任何线程都可以去获取该锁。当某一线程请求获取锁成功后,JVM会记下锁的持有线程,并且将计数器置为1;此时如果其它线程来请求该锁必须等待;但是如果该持有锁的线程如果再次请求这个锁那么它可以再次拿到这个锁同时JMV会将计数器+1;当线程退出同步代码块时,计数器会-1,直到计数器为0时该锁被释放。

按照之前JVM的设计,每次加锁解锁都采用CAS操作,而CAS会引发本地延迟(下面会讲原因),因此偏向锁希望线程一旦获取到监视对象后,之后让监视对象偏向这个锁,进而避免多次CAS操作,说白了就是设置了一个变量,发现是这个线程过来的就避免再走加锁解锁流程。这就引入了JDK1.6新增的偏向锁概念,这个下一篇讲解

synchronized同步机制实现原理

  1. Contention List: 竞争队列,所有请求锁的线程首先被放在这个竞争队列中
  2. Entry List: Contention List中那些有资格成为候选资源的线程被移动到Entry List中
  3. Wait Set: 那些调用wait方法被阻塞的线程被放置在这里
  4. OnDeck: 任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck
  5. Owner: 当前已经获取到所资源的线程被称为Owner
  6. !Owner: 当前释放锁的线程
    synchronized加锁机制
    线程竞争锁过程大概为:
    1. 线程经过自旋操作仍未获取锁资源,进入ContentionList等待队列
    2. Owner在unlock释放锁的时候JVM将一部分线程移动到EntryList中作为候选竞争线程。
    3. 紧接着将EntryList中的某个线程设置为OnDeck线程(一般是最先进去的那个线程)
    4. OnDeck线程去竞争锁,如成功则变为Owner线程,反之继续留在EntryList中。
    5. 如果Owner被wait方法阻塞,则进入WaitSet队列中,此时释放锁资源,其他线程继续竞争。如果Owner被sleep则进入等待队列,此时不释放锁,其他线程也竞争不到锁则该锁上的线程一直被阻塞。
    6. WaitSet中被wait直到被notify或者notifyAll之后再重新加入到EntryList中,被sleep的线程直到timeout被重新变为Owner执行线程。

步骤2、3是因为并发情况下,ContentionList会被大量的并发线程进行CAS访问,为了降低对尾部元素的竞争,Owner线程会在unlock时,将ContentionList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为OnDeck线程。

ContentionList并不是真正意义上的一个队列。仅仅是一个虚拟队列,它只有Node以及对应的Next指针构成,并没有Queue的数据结构。每次新加入Node会在队头进行,通过CAS改变第一个节点为新增节点,同时新增阶段的next指向后续节点,而取数据都在队列尾部进行。
线程等待队列结构

synchronized是自旋锁:

线程的上下文切换: CPU执行任务是通过时间分片来完成的,任务从保存到再加载的过程就是一次上下文切换。

自旋其实就是一个自我循环的过程。

在引入自旋锁的概念之前我们先来了解操作系统阻塞线程的原理:
一个线程被阻塞的过程(也就是要进入Waiting Queue或者Block Queue时,后续有图例讲解)是由操作系统完成的(在Linux下通过pthred_mutex_lock函数),这一过程会导致系统由用户态切换到内核态,后续如果该线程得到cpu执行权之后系统又由内核态转为用户态,而系统在用户态和内核态之间的来回切换及其消耗性能。

缓解上述问题的办法便是加入自旋锁的概念,其原理是:
当发生争用时,若Owner线程能在很短的时间内能释放锁,则那些正在争用线程可以稍微等一等(自旋),在Owner线程释放锁后,争用线程可能会立即得到锁,从而避免该线程进入阻塞状态损耗性能。
但Owner运行的时间可能会超出了临界值,争用线程自旋一段时间后还是无法获得锁,这时争用线程则会停止自旋进入阻塞状态(后退)。究其根本就是要阻塞之前先自旋,只有自旋之后依旧未获得锁再阻塞从而尽量降低阻塞的可能性,这对那些执行时间很短的代码块来说有非常重要的性能提高。

很显然,自旋在多处理器上才有意义。

假如cpu是单处理器,那么一个处理器同时只能执行一个线程,那么就无法做到Owner执行同步代码的同时争用线程执行自旋,那就无法阻止系统用户态和内核态之间的切换。

自旋的实现可以是执行几次for循环,也可以是执行几条空的汇编指令,目的是占着CPU不放,等待获取锁的机会。 所以自旋的周期选择显得非常重要,如果旋的时间过长会影响整体性能,时间过短又达不到延迟阻塞的目的。

对自旋锁周期的选择上,HotSpot认为最佳时间应是一个线程上下文切换的时间,但是目前并没有做到,其实就是如果你自旋的周期比系统状态切换的时间还长那就没有意义了。

HotSpot针对当前CPU的负荷情况做了较多的优化

  1. 如果平均负载小于CPUs则一直自旋
  2. 如果有超过(CPUs/2)个线程正在自旋,则后来线程直接阻塞
  3. 如果正在自旋的线程发现Owner发生了变化则延迟自旋时间(自旋计数)或进入阻塞
  4. 如果CPU处于节电模式则停止自旋
  5. 自旋时间的最坏情况是CPU的存储延迟(CPU A存储了一个数据,到CPU B得知这个数据直接的时间差)
  6. 自旋时会适当放弃线程优先级之间的差异

这些优化策略了解即可,这属于系统内部实现,不同的操作系统的实现方式也不一样故无需深究。

Synchronized在线程进入ContentionList时,等待的线程就通过自旋先获取锁,如果获取不到就进入ContentionList,这明显对于已经进入队列的线程是不公平的,人家原先已经进入等待队列的线程想要讲究个先来后到,而后进的线程其实并不需要遵循这个规则,只要进入了等待队列就拥有同样的执行权,cpu在调度线程的时候一视同仁。还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占OnDeck线程的锁资源。



这篇关于Java锁机制之synchronized的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程