1.java中的关键字,在JVM层面上围绕着内部锁(intrinsic lock)或者监管锁(Monitor Lock)的实体建立的,Java利用锁机制实现线程同步的一种方式。
2.synchronized属于隐式锁,相比于显示锁如ReentrantLock不需要自己写代码去获取锁和释放锁。
3.synchronized属于可重入锁,当一个线程得到一个对象锁后,再次请求此对象锁时是可以再次得到该对象的锁的。即 synchronized块中的synchronized还是能马上获得该锁。
4.synchronized为非公平锁,即多个线程去获取锁的时候,会直接去尝试获取,如果能获取到,就直接获取到锁,获取不 到的话进入等待队列。
5.jdk1.6之前,synchronized属于重量级锁(悲观锁),jdk1.6之后被进行了大幅度优化,支持锁升级制度缓解加锁和解锁造 成的性能浪费,锁的级别采用: 偏向锁 -> 轻量级锁 -> 重量级锁。
synchronized的使用方式主要有两种,分别是:
1.对方法加锁(对普通方法加锁,分为普通方法和静态方法):
a.对普通方法加锁,即为对当前实例对象加锁,同一个类创建的不同对象调用该方法所获取的是不同的锁,所以不会有影 响。
b.对静态方法加锁,静态方法属于类,同一个类创建的不同对象调用该方法时是互斥的,此时的锁对象是class对象。
2.对方法块加锁:
锁是括号里面的对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
1.原子性:synchronized依靠两个字节码指令monitorenter和monitorexit,可以保证被synchronized修饰的代码在同一时间只能被一个线程访问。
2.可见性:JMM(Java内存模型)规定,内存主要分为主内存和工作内存两种,每个线程拥有不同的工作内存,线程工作时会从主内存中拷贝一份变量到工作内存中。代码执行后,有时工作内存中的变量无法及时刷新到主内存中,或者工作内存无法及时获取主内存的最新值,导致共享变量在不同线程间处于不可见性,由此JMM对synchronized做了2条规定:
a.线程解锁前,必须把变量的最新值刷新到主内存中。
b.线程加锁时,先清空工作内存中的变量值,从主内存中重新获取最新值到工作内存中。
3.有序性:有时候编译器和处理器为了提升代码效率,会进行指令重排序,但是as-if-serial规定无论怎么重排序,单线程程序的执行结果都不能被改变,而synchronized保证了被修饰的程序在同一时间内只能被同一线程访问,所以其也算是保证了有序性,但synchronized实际上并不是禁止了被修饰的代码指令重排序。
对可见性或重排序的测试:
public class VisibilityTest {
int a;
public int getValue() {
return a;
}
public void setValue(int a) {
this.a = a;
}
public static void main(String[] args) {
VisibilityTest visibilityTest = new VisibilityTest();
for (int i = 0; i < 8; i++) {
new Thread(() -> {
int x = 0;
while (visibilityTest.getValue() < 100) {
x++;
}
System.out.println(x);
}).start();
}
System.out.println("子线程都已经开始运行了");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
visibilityTest.setValue(200);
System.out.println("主线程马上要结束了");
}
}
结果:
如图可见只有六个线程输出了结果,剩下的还在循环之中,解决办法是给变量加volatile修饰或者给 getValue()方法加synchronized修饰:
public class VisibilityTest {
int a;
public synchronized int getValue() {
return a;
}
public void setValue(int a) {
this.a = a;
}
public static void main(String[] args) {
VisibilityTest visibilityTest = new VisibilityTest();
for (int i = 0; i < 8; i++) {
new Thread(() -> {
int x = 0;
while (visibilityTest.getValue() < 100) {
x++;
}
System.out.println(x);
}).start();
}
System.out.println("子线程都已经开始运行了");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
visibilityTest.setValue(200);
System.out.println("主线程马上要结束了");
}
}
结果:
8个线程全部执行完毕,我在这里进行了多次测试,有时是能够顺利执行的有时是不能的,在这段程序中变量a的访问没有使用任何同步措施(如volatile、锁、final等)。编译器会认为这个变量不会被多个线程共享。从而可能对线程中的循环进行循环不变表达式优化,变成了类似如下的代码:
if(visibilityTest.getValue() < 100){
while(true){
x++;
}
}
具体验证可以通过查看JIT编译器生成的汇编代码,另外有大神指出,要看到这个效果,必须使用server模式java虚拟机。这是因为client模式虚拟机不会执行循环不变表达式优化。不过我在client模式下也出现过这种情况,只是概率很小,这里的输出结果就是client模式的,可以看到只有两个线程进入了无限循环而其他线程都顺利执行了,这里还有待验证,并且在该模式下网上其他大神的一些验证可见性的代码都能够顺利执行,但是切换到server模式后,的确都发生了无限循环的情况,并且上述代码同样在server模式下运行,所有的子线程都进入了无限循环之中。
首先我们来看一下java对象头的相关观念:
由此我们可知,对于为对象加锁的信息主要存在于Mark Work中
Mark Word:存储对象自身的运行时数据,如对象的HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等。而且Mark Word中的LockWord存储了指向monitor的起始地址。
Monior:在java中每个对象天生就带了一把内部锁或者Monitor锁,Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,其结构如下:
synchronized对代码块加锁需要依靠两个指令 monitorenter 和 monitorexit
1.在进入代码块前执行 monitorenter 指令
2.在离开代码快前执行 monitorexit 指令
获取monitor的过程:
1.执行 monitorenter 指令后,当前线程试图获取对象所对应的 monitor 的持有权,当monitor的进入计数器为0,则该线程可以成功获取 monitor,并将计数器值设置为1,此时取锁成功。
2.如果当前线程已经拥有该对象 monitor 的所有权,那它可以进入这个 monitor ,重入计数器的的值加1。
3.如果其他线程已经拥有该对象 monitor 的所有权,那么当前线程将会被阻塞,直到正在执行的线程执行完毕,即 monitorexit 指令被执行,执行线程将释放 monitor锁并将计数器值设为0。
对方法的加锁并不依靠 monitorenter 和 monitorexit 指令,JVM可以从fangfa常量池中的方法表结构中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否是同步方法。当该方法被调用时,调用指令会检查方法的 ACC_SYNCHRONIZED 是否被设置,如果 ACC_SYNCHRONIZED 被设置了,则执行线程率先持有 monitor锁,然后再执行方法,执行结束(或者发生异常并抛到方法之外时)时释放monitor。
如开头所述,JDK1.6之前synchronize是标准的重量级锁(悲观锁),JDK1.6之后进行了大幅度优化,支持锁升级制度缓解加锁和解锁造成的性能浪费,锁的状态总共有四种,无锁、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级到重量级锁,并且锁只能升级不能降级。
经过大量研究发现,大多数情况下锁是不存在多线程竞争的,而且总是会由同一线程多次获得,因此为了减少同一线程加锁解锁的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时 Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作即可再次获取锁,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以对于没有锁竞争的场合,偏向锁有很好的优化效果,但是在有多线程竞争锁的场合,偏向锁就失效了,这种场合下不应该使用偏向锁,否则会得不偿失,偏向锁失败后,将会优先升级为轻量级锁。
偏向锁的工作过程:
当 线程a 访问代码块并获取锁对象时,会通过 CAS 在 Mark Word 中记录偏向的锁的 threadID,因为偏向锁不会主动释放锁,因此以后再次获取锁的时候,需要比较当前线程的 threadID 和 Mark Word 中的threadID是否一致,如果一致,则无需使用CAS来加锁、解锁;如果不一致,则是因为有其他线程如 线程b 来竞争该锁,而偏向锁时不会主动释放锁,因此 Mark Word 存储的还是 线程a 的threadID,那么需要查看 Mark Word 中记录的 线程a 是否存活,如果没有存活,那么锁对象被重置为无锁状态,线程b 可以竞争将其设置为偏向锁;如果存活,那么立刻查找 线程a 的栈帧信息,如果还是需要继续持有这个锁,那么暂停当前 线程a,撤销偏向锁,升级为轻量级锁,如果 线程a 不再使用该锁,那么将锁状态设为无锁状态,重新偏向新的线程。
在java中偏向锁是默认开启的,绝大多数 情况下,对于加锁的程序大多都会有两个以上的线程去竞争,如果开启偏向锁,反而会加剧锁的资源消耗,可以通过jvm参数启动或关闭偏向锁:
-XX:-UseBiasedLocking = false
偏向锁的启动延迟默认为5秒,可以取消这个延迟:
XX:BiasedLockingStartUpDelay=0
轻量级锁是由偏向锁升级而来,它考虑的情况是竞争锁的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,性能的浪费就太大了,因此这个时候就干脆不阻塞这个线程,让它CAS自旋等待锁释放。
轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,轻量级锁在加锁过程中,用到了自旋锁来避免因为多线程的竞争而把线程马上在操作系统层面挂起的情况。
例如:线程a 获取轻量级锁时会先把锁对象的 Mark Word 复制一份到 线程a 的栈帧中存储锁记录的 LockRecord 中,然后使用cas操作把对象头的 Mark Word 的内容替换为 线程a 的 LockRecord 地址,并将Lock record里的owner指针指向对象的 Mark Word,如果在 线程a 复制对象头的同时(在 线程a cas之前),线程b 也准备获取锁,复制了对象头到 线程b 的锁记录空间中,但是在 线程b cas 的时候,发现 线程a 已经把对象头替换了,则 线程b 获取锁失败,那么 线程b 就尝试使用自旋锁来等待 线程a 释放锁。
自旋锁:
虚拟机为了避免多线程的竞争而使线程马上在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程过于浪费性能,因此自旋锁会假设在较短的时间内,当前的线程便可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环原地等待(自旋),默认情况下自旋的次数是 10 次,在经过若干次循环后,如果得到锁,就顺利进入临界区。但是如果自旋次数到了持锁线程还还没有释放锁,或者持锁线程还在执行,下个线程还在自旋等待,这时又有第三个线程过来竞争这个锁,那就会将线程在操作系统层面挂起,这就是自旋锁提升效率的优化方式。如果自旋结束还是成功获取锁,则升级为重量级锁了。
不过需要注意的是,自旋会消耗 cpu。所以轻量级锁适用于那些同步代码块执行的很快的场景。
jdk1.6中自旋锁默认启用,默认情况下自旋的次数是 10 次, 可以通过JVM参数修改:
-XX:PreBlockSpin=10
自适应自旋锁:
在 JDK1.6 之后,引入了自适应自旋锁,自适应意味着自旋的次数不是固定不变的,而是根据同一个锁上一次自旋的时间和拥有锁线程的状态来决定,目的是最大的提高处理器资源利用率。
对于某个锁,如果线程通过自旋成功获得过该锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
当轻量级锁膨胀到重量级锁之后,意味着线程只能被真正的挂起阻塞,然后等待被唤醒。重量级锁的实现方式即为第四节所属的利用每个对象都用的 monitor 内置锁。
下图为各个Mark Work中可能的存储结构:
各个锁的优缺点:
理论上来说,编程时我们会尽量将锁限制在尽量小的范围内,仅在共享数据的实际作用域中才进行同步,目的是使需要同步的操作尽可能缩小,缩短阻塞时间,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
但是加锁解锁会消耗资源,如果存在一系列的连续加锁解锁操作,可能会导致不必要的性能损耗。锁粗化就是此时我们可以矿大枷锁的范围,避免反复加锁和解锁。
Java虚拟机在JIT编译时,通过对运行上下文的扫描,经过逃逸分析,去除不可能存在共享资源竞争的锁,从而提高性能和响应时间。