概念

是一种JVM的隐式锁,因为加锁和解锁过程都是JVM搞定的,不需要我们手动加锁解锁。

历史

以前的时候,jdk 小于 1.6时,synchronized的效率很低。当小于1.6 的时候,锁依赖于Java里的对象,每一个对象又会有一个Monitor管程,Monitor则依赖于Mutex互斥量,这个互斥量由操作系统来维护。JVM是运行在用户态的,如果要操作这个互斥量,就需要切换到内核态,该切换过程是一个重型操作,效率非常低。

由于synchronize效率太低了,doug li 发明了AQS,然后发明了ReentrantLock,效率比sychronized高。采用了CAP来做乐观锁,CAS又可称之为轻量级锁,线程并未阻塞,而是通过自旋来不断尝试修改值。

JDK>=1.6以后,对sychronized进行优化,会主键升级锁,从偏向锁 -> 轻量级锁 -> 重量级锁。

image.png

CAS是如何保证原子性的?

通过底层汇编命令 lockcmpxchgq 来先加锁,然后再去做比较和交换。默认加的是缓存行锁,但是如果数据过大,需要放到几行缓存里,那么就会加总线锁。

因此,虽然CAS在代码层面看起来没有锁,但是在底层硬件级别还是有锁存在的。

CAS的ABA问题如何解决?

使用版本号。

锁升级过程

在1.6版本以后,对synchronized进行了优化,添加了不同级别的锁的状态和锁的升级。

一般流程是:无状态 -> 偏向锁 -> 轻量级锁 -> 重量级锁 ,

注意,如果没有开启偏向锁,直接加锁,那么会从无状态 -> 轻量级锁,跳过了偏向锁。

为什么需要锁升级?

因为有时候虽然我们加了synchronized锁,希望来防止线程安全问题的出现,但是大部分时间下,可能被加锁的代码块只有一个线程在运行。

如果一上来就加一个重量级锁,那么对资源的损耗是相当大的。

偏向锁

偏向锁是比轻量级锁还要轻量的锁,它仅仅在线程对象头中存储当前上锁的线程的ID,每次运行时仅仅比较当前线程的ID和添加到对象头中的ID是否一致,一致则可以继续运行。

通过偏向锁就可以改善在只有一个线程运行时的效率。

轻量级锁

当有少量线程进入时,都想去加锁时,使用CAS + 自旋来升级到轻量级锁。

重量级锁

当加锁的线程非常多时,那就有非常多的自旋,此时轻量级锁效率降低,就采用重量级锁。

sychronized的三种用法

  1. 加在静态方法上: ```java public class Clazz1 { public static sychronized void method1() {
    1. ...
    } }
此时,锁加在了类上,即加在了Clazz1.class上

2. 加在普通方法上:
```java
public class Clazz2 {
    public sychronized void method2() {
        ...
    }
}

此时加在了本对象,即this上。

  1. 同步代码块:
    sychronized(object) {
     xxx
    }
    
    此时加在了object对象上。

锁的状态存在哪?

在加锁时,是对某个对象加锁,对象在堆上。堆上的对象有个对象头,其中就包含了锁的状态。
image.png

image.png
注意:当锁标志位为01时,有两种情况,要么是无锁,要么是偏向锁,此时我们要通过倒数第三位(倒数第二列)为0或者1来判断是否是偏向锁。

偏向锁分析

我们通过jol依赖来打印出来对象的头部,来判断加锁状态,例如:
image.png
第一行的4个字节 + 第二行的4个字节表示MarkWord,刚好64位。注意要反过来和64位虚拟机的图做对比,例如打印出来的第一个8位00000001实际上对应的是表中的后8位。

第三行的4个字节表示元数据指针;

接下来的字节表示实例数据。

最后的4个字节是对齐填充,因为JVM要求对象必须能被8个字节整除,这是为了保证最优的寻址方式。

我们拿下面的代码一步步分析:

public class TestLockUpgrade {

    public static void main(String[] args) throws InterruptedException {
        User userTemp = new User();
        System.out.println("无状态(001):" + ClassLayout.parseInstance(userTemp).toPrintable());

        // JVM默认需要4秒才会开启偏向锁,所以这里先睡5秒
        Thread.sleep(5000);

        User user = new User();
        System.out.println("启用偏向锁(101):" + ClassLayout.parseInstance(user).toPrintable());

        for (int i = 0; i < 2; i++) {
            synchronized (user) {
                System.out.println("偏向锁(101)(带线程id):" + ClassLayout.parseInstance(user).toPrintable());
            }

            System.out.println("偏向锁释放(101)(带线程id): " + ClassLayout.parseInstance(user).toPrintable());
        }
    }

}

class User {
    private int id;
    private String name;
}

第一份打印:
image.png
可以看到,第一个8位为00000001, 最后三位为001,对应到表中为:
image.png
说明此时为无锁状态.

第二份打印:
过了5秒打印了user对象(因为JVM默认4秒以后才启用偏向锁),注意启用偏向锁并不代表已经加了偏向锁,但是如果想要加偏向锁,需要先启用偏向锁。
image.png
image.png

第三份打印:
给user对象加了偏向锁,此时要保存线程的ID到Mark Word里面去,所以前面54位发生了变化。

image.png

第四份打印:
image.png
按照正常理解,运行完了同步代码块里的代码,应该释放锁,但是事实上,并不会释放偏向锁,因为偏向锁假定只有一个线程在运行,为了保证下一次该线程再次执行的效率,并不释放偏向锁。这样子当该线程下一次过来的时候,只需要比对偏向锁中的ID和该线程ID,如果一致那么直接执行。

轻量级锁分析

当有第二个线程来竞争锁的时候,会立刻从偏向锁升级到轻量级锁:
我们添加以下代码来竞争锁。

new Thread(() -> {
    synchronized (user) {
        System.out.println("轻量级锁(00)" + ClassLayout.parseInstance(user).toPrintable());

        try {
            System.out.println("睡眠3秒钟==============");
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}).start();

image.png
可以看到第一个8bit的后两位为00,表示轻量级锁。

重量级锁分析

而如果我们再开启一个线程来竞争锁,因为在轻量级锁分析的过程中,让某个线程抱着锁睡了3秒钟,那么这个新的线程就竞争不到锁,就会将轻量级锁升级成重量级锁。

image.png
可以看到,第一个8bit的后两位变成了10,即重量级锁。