Java小白系列(二):关键字Synchronized

一、前言

Synchronized用于线程同步,相信大家都知道,但具体是如何保证线程同步的,有什么要求?今天我们就来聊聊这些。

二、Synchronized同步的是什么

首先,需要明确一下几个前提:

  • 有多个线程在运行;
  • 都需要访问同一个对象(或叫资源),并对资源做操作;

注:

  • 如果不同的线程访问同一个类型的不同实例,就不存在同步这么一说!
  • 对于上一点需要说明:多线程操作的静态方法同步是属性类的同步(类锁)

所以,这就涉及到锁的形态有两种:『类锁』和『对象锁』。

我们看个例子(多线程访问同一对象,未添加任何的同步措施)

public class Main {
    private int ticket = 100;

    public void decrease() {
        ticket --;
        System.out.println("after tickets = " + ticket + ", " + Thread.currentThread().getName());
    }

    public static void main(String[] args) {
        Main ticket = new Main();

        for (int i = 0; i < 5; i ++) {
            new Thread(() -> {
                try {
                    Thread.sleep(0);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                ticket.decrease();
            }).start();
        }
    }
}

输出日志:

after tickets = 98, Thread-0
after tickets = 96, Thread-3
after tickets = 95, Thread-4
after tickets = 97, Thread-2
after tickets = 98, Thread-1

我们发现个问题:;

如果我们对『decrease』添加『 synchronized』关键字,再来看看结果:

public class Main {
    private int ticket = 100;

    public synchronized void decrease() {
        ticket --;
        System.out.println("after tickets = " + ticket + ", " + Thread.currentThread().getName());
    }

    public static void main(String[] args) {
        Main ticket = new Main();

        for (int i = 0; i < 5; i ++) {
            new Thread(() -> {
                try {
                    Thread.sleep(0);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                ticket.decrease();
            }).start();
        }
    }
}

输出日志:

after tickets = 99, Thread-0
after tickets = 98, Thread-3
after tickets = 97, Thread-2
after tickets = 96, Thread-1
after tickets = 95, Thread-4

虽然线程执行的顺序还是随机的,但至少数据是对的。

三、Synchronized的用法

通过上一小节,我们初步了解 Syncrhonized 的作用,这节,我们具体的说说 Synchronized。

Java提供了一种内置锁的机制来支持原子性、可见性、有序性和可重入性:同步代码块(Synchronized Block);同步代码块包含两部分:

  • 锁的对象引用;
  • 锁保护的代码块;

每一个 Java 对象都可以用于实现一个同步的锁,这种锅被称为『内置锁(Intrinsic Lock)』或『监视器锁(Monitor Lock)』。线程进入同步代码块之前会自动去尝试获取锁,一旦拿到锁,就能进入同步代码块,在退出同步代码块(正常返回或异常退出)时,都会自动释放锁。

3.1、Synchronized 特性

3.1.1、原子性

一个或多个操作,要么全部执行并且执行的过程不会被任何因素打断;要么就都不执行

在 Java 中,基本数据类型变量的读了和赋值操作是原子性操作,这些操作不可中断,要么执行,要么不执行。但是像『i++』『i += 1』等操作就不是原子性的,它们被分成:读取、计算、赋值三步操作,原值在这些步骤还没完成时可能就已经被赋值,那么最后赋值写入的数据不是脏数据 ,因此无法保证原子性。

Synchronized 修饰的类或对象,其所有操作都是原子性的,因为在执行前,必需先获取类或对象的锁,之后才能执行,执行完后需要释放。执行过程中无法被中断,即保证了原子性。

3.1.2、可见性

多个线程访问同一个资源时,该资源的状态、值信息等对于其它线程都是可见的

Synchronized 和 volatile 都具有可见性,其中 Synchronized 对一个类或对象加锁时,一个线程如果要访问该类或对象必须先获得它的锁,而这个锁的状态对于其他任何线程都是可见的,并且在释放锁之前会将对变量的修改刷新到主存当中,保证资源变量的可见性,如果某个线程占用了该锁,其他线程就必须在锁池中等待锁的释放。

而 volatile 的实现类似,被 volatile 修饰的变量,每当值需要修改时都会立即更新主存,主存是共享的,所有线程可见,所以确保了其他线程读取到的变量永远是最新值,保证可见性。

3.1.3、有序性

执行的顺序按照代码先后执行。

Synchronized 和 volatile 都具有有序性,Java允许编译器和处理器对指令进行重排,但是指令重排并不会影响单线程的顺序,它影响的是多线程并发执行的顺序性。Synchronized 保证了每个时刻都只有一个线程访问同步代码块,也就确定了线程执行同步代码块是分先后顺序的,保证了有序性。

3.1.4、可重入性

Synchronized 和 ReentrantLock 都是可重入锁。当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁。通俗一点讲就是说一个线程拥有了锁仍然还可以重复申请锁。

3.2、Synchronized作用

上面说了那么多内容,总结其作用为以下:

  • 确保线程互斥;
  • 确保当前只有一个线程可以操作;
  • 保证共享的资源可修改(原子性),且有可见性;
  • 有效解决重排序问题;

3.3、Synchronized用法

  • 修饰普通方法
  • 修饰代码块
  • 修饰静态方法

四、实战演示

4.1、多线程 + 未同步方法调用

public class SyncTest {
    public void first() {
        System.out.println(System.currentTimeMillis() + " first --- in");
        try {
            System.out.println(System.currentTimeMillis() + " first --- exec");
            Thread.sleep(2000);
        } catch (Exception e) {
        }
        System.out.println(System.currentTimeMillis() + " first --- out");
    }

    public void second() {
        System.out.println(System.currentTimeMillis() + " second --- in");
        try {
            System.out.println(System.currentTimeMillis() + " second --- exec");
            Thread.sleep(1000);
        } catch (Exception e) {
        }
        System.out.println(System.currentTimeMillis() + " second --- out");
    }

    public static void main(String[] args) {
        SyncTest test = new SyncTest();

        for (int i = 0; i < 2; i ++) {
            final int idx = i;
            new Thread(() -> {
                if (idx == 0) {
                    test.first();
                } else {
                    test.second();
                }
            }).start();
        }
    }
}

执行结果如下:

1612098570725 first --- in
1612098570726 first --- exec
1612098570725 second --- in
1612098570726 second --- exec
1612098571727 second --- out
1612098572729 first --- out

我们看到,两个线程是并发执行,因为『second方法中休眠一秒』,所以,后启动执行的线程先执行完成。

4.2、多线程 + 同步方法调用

稍微修改一下上面的代码,如下:

public class SyncTest {
    public synchronized void first() {
        System.out.println(System.currentTimeMillis() + " first --- in");
        try {
            System.out.println(System.currentTimeMillis() + " first --- exec");
            Thread.sleep(2000);
        } catch (Exception e) {
        }
        System.out.println(System.currentTimeMillis() + " first --- out");
    }

    public synchronized void second() {
        System.out.println(System.currentTimeMillis() + " second --- in");
        try {
            System.out.println(System.currentTimeMillis() + " second --- exec");
            Thread.sleep(1000);
        } catch (Exception e) {
        }
        System.out.println(System.currentTimeMillis() + " second --- out");
    }

    public static void main(String[] args) {
        SyncTest test = new SyncTest();

        for (int i = 0; i < 2; i ++) {
            final int idx = i;
            new Thread(() -> {
                if (idx == 0) {
                    test.first();
                } else {
                    test.second();
                }
            }).start();
        }
    }
}

执行结果如下:

1612098877346 first --- in
1612098877346 first --- exec
1612098879350 first --- out
1612098879350 second --- in
1612098879350 second --- exec
1612098880355 second --- out

我们看到,与上一个例子相比,第二个线程必需等到第一个线程完全执行结束,才开始进入并执行。

4.3、多线程 + 同步代码块调用

public class SyncTest {
    public void first() {
        System.out.println(System.currentTimeMillis() + " first --- in");
        try {
            synchronized (this) {
                System.out.println(System.currentTimeMillis() + " first --- exec");
                Thread.sleep(2000);
            }
        } catch (Exception e) {
        }
        System.out.println(System.currentTimeMillis() + " first --- out");
    }

    public void second() {
        System.out.println(System.currentTimeMillis() + " second --- in");
        try {
            synchronized (this) {
                System.out.println(System.currentTimeMillis() + " second --- exec");
                Thread.sleep(1000);
            }
        } catch (Exception e) {
        }
        System.out.println(System.currentTimeMillis() + " second --- out");
    }

    public static void main(String[] args) {
        SyncTest test = new SyncTest();

        for (int i = 0; i < 2; i ++) {
            final int idx = i;
            new Thread(() -> {
                if (idx == 0) {
                    test.first();
                } else {
                    test.second();
                }
            }).start();
        }
    }
}

执行结果如下:

1612099286790 first --- in
1612099286790 first --- exec
1612099286790 second --- in
1612099288795 second --- exec
1612099288795 first --- out
1612099289800 second --- out

我们看到,线程2进入到『second』方法中,但在执行同步代码块之前,需要等待线程1执行完同步代码块,才能开始执行。

4.4、多线程 + 静态方法(类)同步

public class SyncTest {
    public static synchronized void first() {
        System.out.println(System.currentTimeMillis() + " first --- in");
        try {
            System.out.println(System.currentTimeMillis() + " first --- exec");
            Thread.sleep(2000);
        } catch (Exception e) {
        }
        System.out.println(System.currentTimeMillis() + " first --- out");
    }

    public static synchronized void second() {
        System.out.println(System.currentTimeMillis() + " second --- in");
        try {
            System.out.println(System.currentTimeMillis() + " second --- exec");
            Thread.sleep(1000);
        } catch (Exception e) {
        }
        System.out.println(System.currentTimeMillis() + " second --- out");
    }

    public static void main(String[] args) {
        SyncTest test1 = new SyncTest();
        SyncTest test2 = new SyncTest();

        for (int i = 0; i < 2; i ++) {
            final int idx = i;
            new Thread(() -> {
                if (idx == 0) {
                    test1.first();   // SyncTest.first() 一样
                } else {
                    test2.second();  // SyncTest.second() 一样
                }
            }).start();
        }
    }
}

执行结果如下:

1612099702500 first --- in
1612099702500 first --- exec
1612099704503 first --- out
1612099704503 second --- in
1612099704503 second --- exec
1612099705505 second --- out

静态方法的同步,本质上是类的同步,因为,静态方法也是属于类的,而无法独立存在。因此,即使『test1』和『test2』是不同的对象,但是它们都属于『SyncTest类』,每个类都有一个默认的锁,叫作『类锁』。

五、Synchronized 原理

对于上面的这些同步的例子,我们本节会一一讲解其原理。

5.1、static 与 non-static 区别:

再了解原理之前,我们先来了解一下 static 与 non-static 的区别:

  • 被 static 修饰的方法、成员都归类所有,该类的所有对象都可以访问;
  • non-static 的方法与成员是归类的实例化对象所有,只有实例化后的对象才能访问;

这也就是为何 static 方法不能访问 non-static 方法或成员。

5.2、同步代码块

public class SyncTest {
    public void first() {
        synchronized (this) {
            System.out.println();
        }
    }
}

编译后,我们通过命令:

javap -v SyncTest.class

查看编译后的 Class 内容:

Classfile /Users/qingye/Desktop/Java/Demo/out/production/Demo/com/chris/test/SyncTest.class
  Last modified 2021年1月31日; size 573 bytes
  SHA-256 checksum e5340e14a3844276f0f11d0b7ce4e3169bfd99c6ffcd68dbfeb0d228c7f764ac
  Compiled from "SyncTest.java"
public class com.chris.test.SyncTest
......
{
  ......

  public void first();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter        // 重点 1
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: invokevirtual #3                  // Method java/io/PrintStream.println:()V
        10: aload_1
        11: monitorexit         // 重点 2
        12: goto          20
        15: astore_2
        16: aload_1
        17: monitorexit         // 重点 3
        18: aload_2
        19: athrow
        20: return
  ......
}
SourceFile: "SyncTest.java"

我们只关注上面的『重点1、2、3』,会发现有三条指令,其中两条相同,共两种指令:『monitorenter』和『monitorexit』,JVM规范中有对这两条指令的解释:

  • monitorenter

Each object is associated with a monitor. A monitor is locked if and only if it has an owner.
The thread that executes monitorenter attempts to gain ownership of the monitor associated
with objectref, as follows:

  • If the entry count of the monitor associated with objectref is zero, the thread enters the
    monitor and sets its entry count to one. The thread is then the owner of the monitor.
  • If the thread already owns the monitor associated with objectref, it reenters the monitor,
    incrementing its entry count.
  • If another thread already owns the monitor associated with objectref, the thread blocks
    until the monitor's entry count is zero, then tries again to gain ownership.

意思如下:

  • 每个对象都有一个『监视器锁』,当 monitor 被占用时,就会处于锁定状态。线程执行『monitorenter』指令时尝试获取 monitor 所有权时,有以下情况:
  • 如果 monitor 为0,线程进入 monitor 并将其设置为 1,此时该线程为 monitor 的所有者;
  • 如果该线程已经拥有 monitor,当它再次进入 monitor 时,则 monitor 再加 1;
  • 如果另一个线程已经拥有 monitor,则该线程阻塞,直到 monitor 为 0,之后再尝试获取 monitor 所有权;
  • monitorexit

The thread that executes monitorexit must be the owner of the monitor associated with the
instance referenced by objectref.

The thread decrements the entry count of the monitor associated with objectref. If as a result
the value of the entry count is zero, the thread exits the monitor and is no longer its owner.
Other threads that are blocking to enter the monitor are allowed to attempt to do so.

意思如下:

执行 monitorexit 的线程必需是 monitor 的所有者。
当线程执行该指令,则 monitor 数减1。如果 monitor 值为0,则线程退出 monitor 并不再拥有它。其它正在被阻塞的线程可以尝试获取这个 monitor 所有权。

通过以上信息,我们了解了 Synchronized 的底层原理:

  • Java 在编译时,将 Synchronized 所包裹的代码块的前、后分别插入『monitorenter』和『monitorexit』两条指令;
  • JVM 通过一个 monitor 对象来监视所有要执行同步代码块的线程,通过竞争来获取 monitor 所有权,才被允许执行同步代码块内的代码;
  • wait / notify 依赖 monitor ,这就是为何只能在同步代码块中才能调用 wait / notify 方法,否则会抛出 java.lang.IllegalMonitorStateException 异常;

        11: monitorexit         // 重点 2
        12: goto          20    // 跳到 line 20
        
        15: astore_2
        16: aload_1
        17: monitorexit         // 重点 3
        18: aload_2
        19: athrow              // throw exception
        20: return

正常情况下的执行(没有发生异常),则会执行到第 12 行,然后执行 goto 执行,跳到第 20 行,return即退出;但是,当我们发生异常时,则不会正常执行 11 / 12 行指令,会执行后面的指令,即异常处理。我们说过,monitorenter 与 monitorexit 必需一一对应,如果不对异常情况插入 monitorexit,则其它被阻塞的线程永远无法进入执行(monitorexit 为将 monitor 减 1)。

5.3、普通同步方法

public class SyncTest {
    public synchronized void first() {
        System.out.println();
    }
}

执行 javap 查看编译后的 Class 信息

......
  public synchronized void first();
    descriptor: ()V
    flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED  // 重点
    Code:
      stack=1, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: invokevirtual #3                  // Method java/io/PrintStream.println:()V
         6: return
      LineNumberTable:
        line 5: 0
        line 6: 6
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       7     0  this   Lcom/chris/test/SyncTest;
}
SourceFile: "SyncTest.java"

我们并没有发现『monitorenter』和『monitorexit』两条指令,但多了一个修饰符:ACC_SYNCHRONIZED 标志。JVM 就是根据该标志来实现方法的同步:当方法被调用时,会先检查是否有该标志,如果有,则执行的线程需要先尝试获取 monitor ,成功获取之后才能执行方法体,之后再释放 monitor 。这种方式是通过隐式的方式来实现,无需插入字节码来完成。

5.4、静态方法(类)同步

该底层实现实际和 5.2 一样,只是一个是普通方法(基于对象锁),一个是静态方法(基于类锁)而已,但都是隐式的通过 monitor 来实现。

......
  public static synchronized void first();
    descriptor: ()V
    flags: (0x0029) ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED  // 重点
    Code:
      stack=1, locals=0, args_size=0
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: invokevirtual #3                  // Method java/io/PrintStream.println:()V
         6: return
      LineNumberTable:
        line 5: 0
        line 6: 6
}
SourceFile: "SyncTest.java"

六、总结

Synchronized 是 Java 并发编程中最常用,也是最简单的,保证线程安全的措施之一。本文期望抛砖引玉,让大家了解 Synchronized 的同时,也能够稍微深入了解 monitor / JVM 底层知识;也期望大家能够进一步深入学习,更好的理解并发编程的机制。

除了使用 Synchronized (类锁或对象锁),还有其它方式的锁,我也会在后面给大家分享。

你可能感兴趣的:(Java小白系列(二):关键字Synchronized)