在并发编程中synchronized是不可或缺的关键字,在Java SE 1.6之前都是重量级锁,后来为了减少获得锁和释放锁带来的性能消耗引入偏向锁和轻量级锁,以及锁的存储结构和升级过程。
一、synchronized 加锁的三种方式
- 修饰实例方法,作用于当前实例,进入同步代码前需要获得当前实例锁
- 修饰静态类,作用于当前类对象加锁,进入同步前要获得当前类对象的锁
- 修饰代码块,指定实例对象作为锁对象,对给定的锁对象加锁,进入同步代码块前要获得给的的对象锁
二、synchronized括号后的对象
synchronized 括号后面的对象是一把锁,在Java中任意一个对象都可以成为锁。简单来说,我们把Object比喻是一个key,拥有这个Key的线程才能执行这个方法。拿到key 以后在执行方法的过程中,这个key是随身携带而且唯一,如果其他线程想访问当前方法或者代码块,因为没有key所以不能访问只能在门口等着,等之前的线程释放key。因此synchronized锁定的对象必须为同一个对象,如果是不同的对象,意味着不同的房间钥匙,对于访问者没有任何影响。
三、synchronized 的字节指令
使用 javap -v
命令可以查看对应代码的字节指令,其中同步代码块的实现使用了 monitorenter
和 monitorexit
指令,对应地隐式执行Lock 和 UnLock 操作,用于保证原子性。其中monitorenter
指令位于同步代码块的开始位置,而monitorexit
指令则位于同步代码块的结束位置,它们是成对存在对应的。
两个指令是排他的,本质是对一个对象的监视器(monitor)的获取与释放。线程代码执行到 monitorenter
指令时,会尝试获取对应的monitor所有权,也就是尝试获取对象的锁,而 monitorexit
就是释放monitor 的所有权。
四、对象在内存中的布局
在Hotspot 虚拟机中,对象在内存中的存储布局分为三部分:
- 对象头(Header),包含对象标记和类元信息,是实现Synchronized的锁对象基础。synchronized 使用的锁对象存储在Java对象头里,它是轻量级锁和偏向锁的关键。
- 实例数据(Instance Data),
- 对齐填充(Padding)
五、Mark Word
Mark Word(对象标记)用于存储对象自身运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标记、线程持有的锁、偏向锁ID、偏向时间戳等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4个字节,也就是32bit)
六、Monitor
- Monitor 是一种用来实现同步的工具
- 与每个Java对象相关联,所有的Java对象天生携带monitor
- Monitor是实现 Synchronized(内置锁)的基础
对象的监视器(monitor)有ObjectMonitor对象实现(C++)
七、锁升级的过程
锁概念:
**自旋锁(CAS):**让不满足条件的线程等待一会看能不能获得锁,通过占用处理器的时间来避免线程切换带来的开销。自旋等待的时间或次数是有一个限度的,如果自旋超过了定义的时间仍然没有获取到锁,则该线程应该被挂起。在 JDK1.6 之后,引入了自适应自旋锁,自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
偏向锁:大多数情况下,锁总是由同一个线程多次获得。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,偏向锁是一个可重入的锁。如果锁对象头的 Mark Word 里存储着指向当前线程的偏向锁,无需重新进行 CAS 操作来加锁和解锁。当有其他线程尝试竞争偏向锁时,持有偏向锁的线程(不处于活动状态)才会释放锁。偏向锁无法使用自旋锁优化,因为一旦有其他线程申请锁,就破坏了偏向锁的假定进而升级为轻量级锁。
轻量级锁:减少无实际竞争情况下,使用重量级锁产生的性能消耗。JVM 会先在当前线程的栈帧中创建用于存储锁记录的空间 LockRecord,将对象头中的 Mark Word 复制到 LockRecord 中,并将 LockRecord 中的 Owner 指针指向锁对象。然后线程会尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针,成功则当前线程获取到锁,失败则表示其他线程竞争锁,当前线程则会尝试使用自旋的方式获取锁。自旋超过一定的次数,或者线程间竞争激烈,则锁会膨胀升级为重量级锁。
重量级锁:通过对象内部监视器(monitor)实现,其中 monitor 的本质是依赖于底层操作系统的 Mutex Lock 实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。线程竞争不用自旋,不会消耗 CPU。但是线程会进入阻塞等待被其他线程唤醒,响应时间缓慢。
Synchronized 结合 Java Object 对象中的 wait,notify,notifyAll
一般被 synchronized 阻塞的线程的唤醒时机取决于获得锁线什么时候执行完同步代码块并且释放锁。那么要如何做到显式控制呢?这时我们需要借助一个信号机制:Object 对象中,提供了 wait/notify/notifyall,可以控制线程的状态。
wait/notify/notifyall 基本概念
wait:表示持有对象锁的线程 A 准备释放对象锁权限,释放 cpu 资源并进入等待状态。
notify:表示持有对象锁的线程 A 准备释放对象锁权限,通知 jvm 唤醒某个竞争该对象锁的线程 X。线程 A synchronized 代码执行结束并且释放了锁之后,线程 X 直接获得对象锁权限,其他竞争线程继续等待(即使线程 X 同步完毕,释放锁对象,其他竞争线程仍然等待,直至有新的 notify,notifyAll 被调用)。
notifyAll:notifyAll 和 notify 的区别在于,notifyAll 会唤醒所有竞争同一个对象锁的所有线程,当已经获得锁的线程 A 释放锁之后,所有被唤醒的线程都有可能获得对象锁权限。
面试题:wait/notify/notifyAll 为什么需要在 synchronized 里面?
- wait 方法的语义有两个,一个是释放当前的对象锁、另一个是使得当前线程进入阻塞队列,而这些操作都和监视器是相关,所以 wait 必须要获得一个监视器锁
- 对于 notify 来说也是一样,它是唤醒一个线程,既然要去唤醒,首先得知道它在哪里?所以就必须要找到这个对象获取到这个对象的锁,然后到这个对象的等待队列中去唤醒一个线程。
- 每个对象可能有多个线程调用 wait 方法,所以需要有一个等待队列存储这些阻塞线程。这个等待队列应该与这个对象绑定,在调用 wait 和notify 方法时也会存在线程安全问题所以需要一个锁来保证线程安全。
wait/notify 的基本原理
资料:
https://blog.csdn.net/baidu_38083619/article/details/82527461