熟悉Java并发编程的朋友,大概率都经历过这样一个阶段:背了无数次synchronized的用法,知道它能保证原子性、可见性和有序性,能修饰方法、代码块,可一旦被问到“synchronized底层到底怎么实现的”“对象头里到底存了什么”“内存屏障和它有什么关系”,就容易卡壳。市面上讲synchronized的文章不少,但很多要么只停留在字节码层的monitorenter/monitorexit,要么直接甩出大片源码注释,真正能把对象头、锁升级、内存屏障这条链路串清楚的并不多见。这篇文章我想换个角度,不贴大段HotSpot源码,而是把这条完整链路拆开揉碎,从Java对象在内存里长什么样开始,一路讲到锁升级、重量级锁的Monitor机制,再到JIT编译阶段锁消除、锁粗化,最后落到硬件层面的内存屏障,一次讲透。不管是还在啃Java基础准备面试,还是已经在实际项目里排查并发问题,这篇文章都值得你花二十分钟认真读完。
1. 从一次面试追问说起:synchronized到底把锁放哪了
先讲个真实场景。有次帮朋友做模拟面试,前几轮八股文都答得挺流畅,问到“synchronized加锁之后,锁的信息储存在哪里”这个问题时,对面沉默了几秒,然后说“存在锁对象里吧”。这个回答方向是对的,我再追问“具体是锁对象的哪个位置?里面存的又是什么?”他就答不上来了。
这个问题的准确答案,藏在Java对象的内存布局里。任何一个Java对象,在HotSpot虚拟机里都由三部分组成:对象头(Object Header)、实例数据(Instance Data)和对齐填充(Padding)。其中对象头又包含两部分:Mark Word(标记字)和Klass Pointer(类型指针),如果对象是数组,还会多一个记录数组长度的字段。
Mark Word就是synchronized锁信息真正的落脚点。它在64位JVM上占8个字节,这64个bit会根据对象所处的状态,存储完全不同的信息:无锁状态下存的是对象哈希码、GC分代年龄;偏向锁状态下存的是持有锁的线程ID、偏向时间戳;轻量级锁状态下存的是指向线程栈中Lock Record的指针;重量级锁状态下存的是指向堆中Monitor对象的指针。
这就牵扯出一个很多初学者没想明白的问题:一个Java对象在创建出来的时候,就已经自带了一套记录锁状态的区域,JVM并没有为“加锁”这个动作额外分配一块独立的锁空间,而是通过复用对象头里那64个bit,在不同状态下赋予它们不同的含义。所以“锁是存在对象里的”这句话并不严谨,准确说是存在对象的Mark Word里。
1.1 Klass Pointer和对象头的容量问题
Klass Pointer在开启压缩指针的情况下占4个字节,用于指向方法区的类元信息。它跟锁没有直接关系,但搞清楚对象头的完整结构,对理解后面锁升级有帮助,所以这里顺便带一句。
还有一个细节值得注意:Mark Word里那64个bit是非常珍贵的存储资源。它既要存哈希码,又要存GC分代年龄,还要在加锁后存线程ID或Monitor指针。所以JVM在设计时就定了一条规矩——偏向锁一旦撤销,对象原始的哈希码如果还没计算过,后面再计算就会导致偏向锁失效。这是因为无锁状态下的哈希码占了31个bit,而偏向锁状态下这31个bit全部被线程ID和偏向标记占用了,没法同时保存两套数据。这也是为什么某些场景下调用System.identityHashCode()会导致偏向锁直接膨胀到轻量级锁的原因。
1.2 无锁状态下的Mark Word长什么样
拿默认开启偏向锁的HotSpot 8举例,一个新建对象如果没被任何线程加锁,它的Mark Word大致长这样:
- 偏向标记位(1 bit):1,表示当前对象处于“可偏向”状态
- 偏向锁标记(2 bit):01(这是锁标志位,和偏向标记位合起来看)
- 分代年龄(4 bit):初始为0
- 哈希码(31 bit):哈希码是lazy计算的,第一次调用
hashCode()后才会写入
这里有个非常容易搞混的概念:偏向标记位和锁标志位是两个不同层次的标记。锁标志位永远是最后两位,它的取值组合决定了锁的当前状态——01表示无锁或偏向锁(具体是哪种要看偏向标记位),00表示轻量级锁,10表示重量级锁,11表示GC标记。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
