要学习java中锁的机制,首先要了解java对象头。因为锁机制的实现依赖于java的对象头。
那什么是java对象头呢?
当你创建一个对象时,该对象在内存中的存储布局为:
对象头含有三部分:Mark Word(存储对象自身运行时数据)、Class Metadata Address(存储类元数据的指针)、Array length(数组长度,只有数组类型才有)。
其中Mark Word和类型指针就被称为对象头。MarkWord中存放的东西下面详细介绍,类型指针里存放着一个地址,指向该对象是哪个类创建的对象。
Mark Word的大小为8个字节,类型指针的大小为4个字节。实例数据和对齐位的大小不一定。对齐位主要是要让该对象大小要被8整除,如果不能被整除就补位。
如何查看对象各个区域所占的大小呢?导入如下依赖并使用ClassLayer类来输出。
输出语句: System.out.println(ClassLayout.parseInstance(对象名).toPrintable()); org.openjdk.jol jol-core 0.9
举个栗子:
public class LayoutTest {
public static class T{
int i = 0;
}
public static void main(String[] args) {
T t = new T();
System.out.println(ClassLayout.parseInstance(t).toPrintable());
}
}
输出如下:
表示该对象大小为16个字节。为什么是16bytes呢?
首先Mark Word占8个字节,class pointer占4个字节,数据类型为int占4个字节,总共16个字节,可以被8整除所以对齐位没有补位。
第二个栗子:
public class LayoutTest {
public static class T{
String s = "abcdefghijklmn";
}
public static void main(String[] args) {
T t = new T();
System.out.println(ClassLayout.parseInstance(t).toPrintable());
}
}
嗯?这字符串s中有这么长一串怎么还是16个字节呢?
因为此处的s其实是一个指针,该指针指向字符串常量池的这个字符串,这个字符串不在这个对象中创建,所以还是12+4+4=16bytes。因为s是指针class pointer也是指针所以都是4个字节。
Mark Word里默认存储对象的HashCode、分代年龄、是否偏向锁以及锁标记位。32位JVM的Mark Word的默认存储结构如下:
Synchronized锁的状态被分为4种:无锁,偏向锁,轻量级锁,重量级锁
注意锁标志汇总没有出现自旋锁,自旋锁仅仅是锁可能存在的一种状态,是暂时性的没有官方的标志。
无锁和偏向锁标志位都是01,只是偏向锁时偏向模式会被置为1,。锁可以升级但是不可以降级,意味偏向锁升级成轻量级锁后不能降级称为偏向锁。
无锁是001状态,偏向标志0,锁标志01;
更准确来说,001状态应该是无锁不可偏,因为还有一种101状态是无锁可偏(又叫匿名偏向)。无锁不可偏状态下遇到同步会直接升级成轻量级锁,而不会变成偏向锁。只有在无锁可偏101状态下才能变成偏向锁。101状态下线程ID部分全部为0,以为着没有线程实际获得偏向锁。
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
这个锁永远会偏向于获得它的线程,线程第二次到达同步代码块时,会判断此时持有锁的线程是否就是自己,如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致STW(stop the word)操作;
锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。 只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。
优点:在只有单一线程访问对象的时候,偏向锁机会没有影响。只有第一次需要cas操作替换,随后的只要比较线程ID即可,比较方便。
缺点:多个线程访问时会出现竞争,一系列分析比较耗费时间。而且偏向锁存放线程ID和Epoch后,对象头中不存在hash值,如果程序需要hash值会导致偏向锁退出。
如果对象需要调用Object方法,会启用对象的minoter内置锁,此时会直接有偏向锁升级为重量级锁。
1)判断锁对象是否是偏向锁(即锁标志位为01,偏向锁位为1),若为偏向锁状态执行2)。
2)判断锁对象的线程ID是否为当前线程的ID,如果是则说明已经获取到锁,执行代码块;否则执行3)。
3)当前线程使用CAS更新锁对象的线程ID为当前线程ID。如果成功,获取到锁;否则执行4)
4)当到达全局安全点,当前线程根据Mark Word中的线程ID通知持有锁的线程挂起,将锁对象Mark Word中的锁对象指针指向当前堆栈中最近的一个锁记录,偏向锁升级为轻量级锁,恢复被挂起的线程。
偏向锁采用一种等到竞争出现时才释放锁的机制。当其他线程尝试竞争偏向锁时,当前线程才会释放偏向锁,否则线程不会主动去释放偏向锁。偏向锁的撤销需要等待全局安全点。
1)首先暂停持有偏向锁的线程。
2)撤销偏向锁,恢复到无锁状态或轻量级锁状态。
轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
轻量级锁:轻量级锁的原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。
设计初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统的互斥量产生的性能消耗。
1)判断是否处于无锁状态,若是,则JVM在当前线程的栈帧中创建锁记录(Lock Record)空间,用于存放锁对象中的Mark Word的拷贝,官方称为Displaced Mark Word;否则执行步骤3)。
2)当前线程尝试利用CAS将锁对象的Mark Word更新为指向锁记录的指针。如果更新成功意味着获取到锁,将锁标志位置为00,执行同步代码块;如果更新失败,执行步骤3)。
3)判断锁对象的Mark Word是否指向当前线程的栈帧,若是说明当前线程已经获取了锁,执行同步代码,否则说明其他线程已经获取了该锁对象,执行步骤4)。
4)当前线程尝试使用自旋来获取锁,自旋期间会不断的执行步骤1),直到获取到锁或自旋结束。因为自旋锁会消耗CPU,所以不能无限的自旋。如果自旋期间获取到锁(其他线程释放锁),执行同步块;否则锁膨胀为重量级锁,当前线程阻塞,等待持有锁的线程释放锁时的唤醒。
1)从当前线程的栈帧中取出Displaced Mark Word存储的锁记录的内容。
2)当前线程尝试使用CAS将锁记录内容更新到锁对象中的Mark Word中。如果更新成功,则释放锁成功,将锁标志位置为01无锁状态;否则,执行3)。
3)CAS更新失败,说明有其他线程尝试获取锁。需要释放锁并同时唤醒等待的线程。
在轻量级锁的状态下,线程都会进行自旋等待,尝试获取锁。但是长时间自旋非常消耗性能,不能让线程无限制的自旋下去,如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁。
当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起,等待将来被唤醒。在JDK1.6之前,synchronized直接加重量级锁,很明显现在得到了很好的优化。
重量级锁的特点:其他线程试图获取锁时,都会被阻塞,只有持有锁的线程释放锁之后才会唤醒这些线程。
| 锁 | 优点 | 缺点 | 使用场景 |
|---|---|---|---|
| 偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 | 适用于只有一个线程访问同步块场景。 |
| 轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度。 | 如果始终得不到锁竞争的线程使用自旋会消耗CPU。 | 追求响应时间。同步块执行速度非常快。 |
| 重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,线程上下文切换耗费资源大,响应时间缓慢 | 追求吞吐量。同步块执行速度较长。 |
CAS(compare and swap),比较并交换,可以解决多线程并行情况下使用锁造成性能损耗的一种机制.CAS 操作包含三个操作数—内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。
通俗来说就是: 比较当前工作内存中的值和主内存中的值,如果相同则执行规定操作,否则继续比较直到主内存和工作内存中的值一致为止.(自旋)
CAS是一条CPU的原子指令,不会造成所谓的数据不一致问题。
CAS存在的问题:
1、ABA问题。因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A - 2B-3A。
2、循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。
举例:让三个线程把num从0加到1000
使用CAS无锁方法:
public class CAS {
static AtomicInteger num = new AtomicInteger(0);
public static void main(String[] args) {
for(int i = 0;i<3;i++){
Thread t = new Thread(new Runnable() {
@Override
public void run() {
while(num.get() < 1000){
System.out.println("Thread name:" + Thread.currentThread().getName() + ":" + num.incrementAndGet());
}
}
});
t.start();
}
}
}
输出
AtomicInteger类:
①.支持原子操作的Integer类
②.主要用于在高并发环境下的高效程序处理。使用非阻塞算法来实现并发控制
点进incrementAndGet()方法,发现调用了unsafe.getAndAddInt(this, valueOffset, 1) + 1;
再进入getAndAddInt中发现调用了compareAndSwapInt方法,这就是CAS方法,其中这个循环就是进行自旋操作,默认为自旋10次.
继续进入compareAndSwapInt方法,发现使用了native修饰符,那么就是一个本地方法,本地方法中使用了汇编命令进行CAS操作



