# 线程同步：synchronized及其原理

### 线程竞争问题

多个线程同时共享并操作同一个数据，就会发生线程竞争。就有可能引发线程安全问题；下面是经典的银行取钱问题：

```java
public class ThreadSafeTest {

    public static class People implements Runnable {

        private int withdrawMoney = 0;

        public static Integer money = 1000;

        public People(int withdrawMoney) {
            this.withdrawMoney = withdrawMoney;
        }

        @Override
        public void run() {
            if (money - withdrawMoney >= 0) {
                money = money - withdrawMoney;
                System.out.println("取钱" + withdrawMoney + "成功，余额为" + money);
            } else {
                System.out.println("取钱" + withdrawMoney + "失败，余额为" + money);
            }
        }
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(new People(200));
        Thread t2 = new Thread(new People(400));
        Thread t3 = new Thread(new People(300));
        Thread t4 = new Thread(new People(100));
        Thread t5 = new Thread(new People(200));

        t1.start();
        t2.start();
        t3.start();
        t4.start();
        t5.start();
    }

}

```

结果：

```
取钱200成功，余额为700
取钱200失败，余额为0
取钱400成功，余额为0
取钱300成功，余额为400
取钱100成功，余额为700
```

导致线程安全问题的根本原因主要是以下两点：

1. 线程是并发执行的，CPU会在多个线程之前来回切换。即有线程竞争
2. CPU所执行的代码指令是多个，不是 **原子** 的。 比如 判断余额、扣减余额、显示余额三个就是三条CPU指令，执行完一个指令后，有可能执行其他线程的有关指令，导致资源混乱。

> 所以避免线程竞争是解决线程安全问题的一种解决方案

### 锁的概念

线程安全问题即是 **并发安全** 问题，无论是并发安全问题还是并行安全问题，都可以使用锁来解决。锁的办法比较直观，只要保证余额判断、扣减金额、显示金额针对统一资源处于同一 **原子操作**，即是线程独占的。

所谓的锁就是在多个线程共同可访问的空间中放置一个标记，假设资源A的锁为： `res_lock`，当 `res_lock=0` 时代表资源A未被任何线程访问，而 `res_lock=1` 时代表资源A已被某个线程访问。

假设Thread1 与 Thread2 共同访问资源A，ThreadA被CPU先选中，开始执行逻辑代码： `判断余额-扣减金额-查询余额`。Thread1发现 `res_lock=0` 说明没有其他线程正在访问此资源，Thread1 将 `res_lock` 置为1——这个过程称为加锁；然后开始执行逻辑。此时Thread2被CPU选中，Thread1发现 `res_lock=1`，他将不会继续执行(会阻塞或者直接返回)——此过程称为加锁失败；此时Thread1再次被CPU选中，继续执行业务逻辑；执行结束后，Thread1将 `res_lock` 重新置为0——这个过程称为所释放。此时阻塞中的Thread2发现 `res_lock=0`，Thread2将会重复Thread1之前的逻辑。

在这个过程中，即使Thread1的操作被CPU打断，但是由于 `res_lock` 标记，其他线程无法执行业务代码，从而保证了Thread1的业务代码的原子性，从而保证了线程安全。

无论是 synchronized 还是 Lock，还是Redis、Zookeeper分布式锁，实现的方式原理都是上述描述的锁的原理。

### synchronized

synchronized 关键字可以给一段代码加锁，既保证了原子性，也保证了可见性。并且是一个可重入锁。

```java
// 加一把锁，锁是object对象
synchronized (object){
    // 拿到锁对象可以执行代码块中的内容，否则不可以
    // ...
    // 结束后进行锁释放
}
```

除此之外，synchronized有以下几个变种：

* 同步方法：

  ```java
  synchronized void method1() {
  }
  // 等同于
  void method1() {
      synchronzied (this) {
      }
  }
  ```
* 静态同步方法：

  ```java
  // ClassA
  static synchronized void method1() {
  }
  // 等同于
  static void method1() {
      synchronzied (ClassA.class) {
      }
  }
  ```

**注意**：

* 锁对象一般为final类型，保证引用不可变；如果引用发生变化，就锁不住了
* 锁对象不能为null，否则会抛出空指针异常
* String、Integer等对象最好不要做为锁，因为String、Integer(0-127)位于常量池中，如果其他jar包中的资源也对同值的String进行了锁定，那么将会出现不可预知的问题。

#### 锁释放

以下情况会进行锁释放：

* 代码块执行完毕
* 同步方法执行完毕
* 代码块、方法抛出异常
* wait，会释放当前的锁对象

以下情况不会进行锁释放：

* sleep
* yield

#### wait、notify、notifyAll

wait方法用在synchronized代码块内，当调用wait方法时，将会释放当前的锁，并让当前线程进入等待状态，这些等待的线程会进入该锁对象的等待池中。

notify方法将会在等待池中，随机换醒一个等待的线程，将其加入到锁池中。锁池中的所有对象会竞争锁。

notifyAll方法将会唤醒所有的等待线程，将他们全都加入到锁池中。

#### 锁原理

了解 synchronized 的实现原理，首先要知道Java对象头的概念。Java对象是存储在堆内存中，一个对象可以大致分为三个部分：

![file](https://2351062869-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F7b2CdwBN9liniVJpfEAc%2Fuploads%2Fgit-blob-8b6d1b4815330a06e1c7f29cbb8b3ae57cb96206%2Fimage-1573628501743.png?alt=media)

* 对象头： 主要由MarkWord和Klass Point(类型指针)组成。
  * MarkWord部分用于存储自身运行时的数据，比如表示对象的线程锁状态、存放该对象的hashCode、配合GC等功能
  * Klass Point是一个指向方法区中Class信息的指针，通过这个指针随时可以知道此对象的类型
  * 如果对象是数组对象，那么就会在第三个位置上记录数组的长度
* 对象体：是用于存储对象属性和值的主体部分，占用空间的大小取决于对象属性的数量和类型
* 填充字符：因为虚拟机要求对象字节必须是8字节的整数倍，填充字符就是用于凑齐这个整数倍的

既然synchronized存储在MarkWord对象头中，那么我们来看一下MarkWord在不同的锁状态下，组成的不同表现（64位虚拟机）：

![file](https://2351062869-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F7b2CdwBN9liniVJpfEAc%2Fuploads%2Fgit-blob-913df5aede7b2b0811b87db1c735bb6166c7b73f%2Fimage-1573628736492.png?alt=media)

首先，这五种状态 —— 正常、偏向锁、轻量级锁、重量级锁、GC标记，分别由64bit中的最后2bit表示，他们分别对应一个数值，具体如下：

状态标志位存储内容正常01，内部还是用based\_lock来标记是否是偏向锁hashCode（identity\_hashcode）、对象分代年龄偏向锁01，内部还是用based\_lock来标记是否是偏向锁偏向线程ID、偏向时间戳、对象分代年龄轻量级锁定00指向锁记录的指针重量级锁10指向重量级锁的指针GC标记11无任何信息，马上就被回收了

* biased\_lock： 对象是否启用偏向锁标记， 1bit， 1表示启用了偏向锁，0表示未启用偏向锁
* age： 4bit，Java对象的年龄，如果对象在Survivor区复制一次，年龄增加1，到达阈值时，会晋升到老年代
* identity\_hashcode： 31bit，hashCode值。对象的hashCode值将会调用System.identityHashCode()计算，然后写入到对象头中。当对象加锁后（偏向、轻量级、重量级），MarkWord的字节没有足够的空间保存hashCode，因此该值会移动到管程Monitor中
* thread： 偏向锁所偏向的线程ID
* epoch： 偏向锁的时间戳
* ptr\_to\_lock\_record：轻量级锁状态下，指向栈中锁记录的指针
* ptr\_to\_heavyweight\_monitor：重量级锁状态下，指向对象监视器Monitor的指针

**锁升级**

在JVM虚拟机规范中，针对synchronized的实现并没有任何规定。早期的实现中，每次加锁都去操作系统申请，是重量级的。因为每次都去操作系统申请，导致synchronzied效率非常低，在JDK1.5之后，采取了锁升级，简单可以理解为先使用轻量级的方式，如果有需要则升级为重量级的方式。以Hotspot为例，锁升级大概经历了以下几个阶段：

* 无锁： 没有任何线程竞争，MarkWord状态处于正常状态，lock状态为01， biased\_lock偏向锁状态为0
* 偏向锁 （偏向某一线程 ）：当有一个线程来竞争锁时，先使用偏向锁，表示对象偏爱这个线程。这个线程在执行这个锁关联的任何代码，不需要进行任何检查和切换，在竞争不激烈的情况下，效率非常高。
* 自旋锁（循环10次）：当有第二个线程去竞争这个锁对象，就不可以使用独占锁了，锁会自动升级为轻量级锁。谁先抢到锁谁就拥有执行权。轻量级锁内部是一个循环，他会不断的去获取锁，直到获取到为止。
* 重量级锁：即OS锁，如果竞争这个锁的线程太多，或者等待的时间过长，内部的循环会导致CPU负载过高，所以锁会自动升级为OS锁，也就是同步锁，此时MarkWord的状态最终会变为10，并指向监视器对象。

注意，锁升级后是无法降级的。

> 执行时间（加锁代码）较长，线程数较多的使用OS锁，短的线程少的适合自旋锁

### 参考资源

* [CSDN tongdanping Java并发——Synchronized关键字和锁升级，详细分析偏向锁和轻量级锁的升级](https://blog.csdn.net/tongdanping/article/details/79647337)
* [CSDN 阿珍爱上了阿强？ Java对象结构与锁实现原理及MarkWord详解](https://blog.csdn.net/scdn_cp/article/details/86491792)
