Java并发之synchronized深度解析

本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布

0. 序言

  • 本篇从示例和理论两方面讲解synchronized关键字,希望对学习并发的你有所帮助。
  • 主要内容:
    • synchronized简介
    • 并发后果
    • 锁分类
    • 对象锁
    • 类锁(可能你觉得这样称呼不合理,称呼而已,暂可不必计较)
    • 查看线程的生命周期
    • 多线程访问同步方法的7种具体情况
    • synchronized的性质
    • 加锁解锁的实现原理
    • 可重入性质的原理
    • Java的内存模型
    • 可见性
    • synchronized线程安全的根本原因
    • synchronized的缺陷
    • 注意点
  • 并发基础需了解的请跳转:
    https://www.jianshu.com/p/1adedd2b2727

1. synchronized简介

  • 作用
    专业:如果一个对象对多个线程可见,则对该对象变量的所有读取和写入都是通过同步方法完成的。
    通俗:能够保证你在同一时刻最多只有一个线程执行该段代码,以达到保证并发安全的效果。
  • 地位
    synchronized是Java的关键字,是最基本的互斥同步手段,是并发编程必学内容。

2. 并发后果

  • 举例:
public class Main  implements Runnable{

    static Main main = new Main();

    static int num = 0;

    @Override
    public void run() {
           for (int i = 0 ;i<10000;i++){
                num++;
            }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(main);
        Thread thread2 = new Thread(main);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(num);
    }
}
11756
20000
11185
10485
  • 说明:
    ① 创建了两个线程thread1和thread2以及定义了一个变量num
    ② thread1.start();thread2.start();意思是开启了thread1和thread2,也就是两个子线程都开始执行run方法。
    ③ thread1.join();thread2.join();意思是thread1子线程执行完再执行主线程,thread2子线程执行完再执行主线程.所以有了这两句代码以后,两个子线程都执行完自己的代码,代码System.out.println(num);才执行。
    ④ 运行结构发现,大多数时候没有达到预期结果20000,那原因在哪里呢?是因为num++这操作,首先Cpu要去内存中读数据,然后赋值+1,然后写入内存,经历三个步骤;假设num值是9,线程thread1读取到了9,并且加了1,但是还没有写入内存,这时候thread2读取到的内存中num的值还是9,所以线程thread1和thread2最后写到内存的值都是10,所以最终num++的结果比预期少,我们把这种情况称为线程不安全。
    ⑤ 其实就是并发不能保证内存的可见性。

3. 锁分类

  • 对象锁
    • 方法锁:默认锁对象为this
    • 同步代码块锁:this或者自定义锁对象
  • 类锁
    • 静态锁:添加static
    • Class对象锁:Main.class

4. 对象锁

4.1 同步代码块锁
  • 锁对象this
 synchronized(this) {
            System.out.println("我是对象锁的代码块形式。我的名字是:"
                    + Thread.currentThread().getName());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "运行结束");
}
①
我是对象锁的代码块形式。我的名字是:Thread-0
②
Thread-0运行结束
我是对象锁的代码块形式。我的名字是:Thread-1
③
Thread-1运行结束
finish

说明:
① 这个时候this指的是谁呢?大家都知道this指代的是当前对象,也就是Main的实例对象。
② 虽然创建了两个线程,但是Runnable的实例对象从来没有变过,也就是this在这里是唯一的,所以线程安全。
② 如果这里用的是继承Thread的方式创建的线程,this就不安全,因为每次创建新的线程,this所指代的内容就会发生变化。

  1. 自定义锁对象
public class Main implements Runnable{

     Object lock1 = new Object();
     Object lock2 = new Object();

    static Main instance = new Main();

    @Override
    public void run() {
        synchronized(lock1) {
            System.out.println("我是lock1部分,我叫"
                    + Thread.currentThread().getName());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + " lock1部分运行结束"));
        }

         synchronized(lock2) {
            System.out.println("我是lock2部分,我叫:"
                    + Thread.currentThread().getName());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + " lock2部分运行结束"));
        }
    }

    public static void main(String[] args) {
        Thread thread1 = new Thread(instance);
        Thread thread2 = new Thread(instance);
        thread1.start();
        thread2.start();
        while(thread1.isAlive()||thread2.isAlive()){

        }
        System.out.println("finish");
    }
}
运行结果:
①
我是lock1部分,我叫Thread-0
②
Thread-0 lock1部分运行结束
我是lock2部分,我叫:Thread-0
我是lock1部分,我叫Thread-1
③
Thread-0 lock2部分运行结束
Thread-1 lock1部分运行结束
我是lock2部分,我叫:Thread-1
④
Thread-1 lock2部分运行结束
finish

说明:
① lock1锁被Thread1释放后,Thread2才拿到lock1的锁。
② lock2锁被Thread1释放后,Thread2才拿到lock2的锁。
③ 试想锁的内容都是lock1,那Thread1的执行完两个代码块的内容后,Thread2才会执行第一个代码块,运行结果会是:

①
我是lock1部分,我叫Thread-0
②
Thread-0 lock1部分运行结束
我是lock2部分,我叫:Thread-0
③
Thread-0 lock2部分运行结束
我是lock1部分,我叫Thread-1
④
Thread-1 lock1部分运行结束
我是lock2部分,我叫:Thread-1
⑤
Thread-1 lock2部分运行结束
finish
4.2 方法锁
  • 举例说明
public class Main implements Runnable{

    static Main instance = new Main();

    @Override
    public void run() {
       method();
    }

    private synchronized void method() {
        System.out.println("方法锁,我的名字是"+Thread.currentThread().getName());
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName()+"运行结束");
    }

    public static void main(String[] args) {
        Thread thread1 = new Thread(instance);
        Thread thread2 = new Thread(instance);
        thread1.start();
        thread2.start();
        while(thread1.isAlive()||thread2.isAlive()){

        }
        System.out.println("finish");
    }
}
方法锁,我的名字是Thread-0
Thread-0运行结束
方法锁,我的名字是Thread-1
Thread-1运行结束
finish

说明:
① 我们给method方法添加了关键字synchronized,线程安全,同一时刻只有一个线程访问这个方法。
② 方法锁的默认锁对象是this。

5. 类锁

5.1 Class对象锁(锁对象是类名.class)
public class Main implements Runnable{

    static Main instance1 = new Main();
    static Main instance2 = new Main();

    @Override
    public void run() {
        synchronized(Main.class) {
            System.out.println("我是类锁的代码块形式。我的名字是:"
                    + Thread.currentThread().getName());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "运行结束");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(instance1);
        Thread thread2 = new Thread(instance2);
        thread1.start();
        thread2.start();
        while(thread1.isAlive()||thread2.isAlive()){

        }
        System.out.println("finish");
    }
}
运行结果:
①
我是类锁的代码块形式。我的名字是:Thread-0
②
Thread-0运行结束
我是类锁的代码块形式。我的名字是:Thread-1
③
Thread-1运行结束
finish

说明:
① 发现添加关键字synchronized后,thread1执行完run方法以后,thread2才会执行,线程安全。
② 只要锁内容唯一,线程就安全。上面的示例的锁内容是Main.class,Main.class始终不变,当jvm加载一个类时就会为这个类创建一个Class对象。而Main.class就是这个Class对象。
③ Java类可能会有很多个对象,但是Class对象只有一个。不过Class对象其实也是存放在堆中的实例对象,只不过比new出来的对象特殊一点,是jvm加载类时所创建的。所以这个Runnable对象不管new多少新的实例传入不同的Thread中,Class对象也只有一个,作为锁的对象,线程安全。

5.2 静态锁(synchronized添加在static方法上)
public class Main implements Runnable{

    static Main instance1 = new Main();
    static Main instance2 = new Main();

    @Override
    public void run() {
        method();
    }

    private static synchronized void method() {
            System.out.println("我是类锁的代码块形式。我的名字是:"
                    + Thread.currentThread().getName());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "运行结束");
    }

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(instance1);
        Thread thread2 = new Thread(instance2);
        thread1.start();
        thread2.start();
        while(thread1.isAlive()||thread2.isAlive()){

        }
        System.out.println("finish");
    }
}
①
我是静态锁的代码块形式。我的名字是:Thread-0
②
Thread-0运行结束
我是静态锁的代码块形式。我的名字是:Thread-1
③
Thread-1运行结束
finish

说明:
如果不添加static,那么因为Runnable的实例对象是两个不同的,所以在访问synchronized修改的普通方法的时候,线程0和线程1都会同时访问到。而添加了static之后,就算不同的实例访问这个方法,那么也只有一个线程可以访问到。

6. 查看线程的生命周期

这里主要介绍如何通过调试,查看线程的状态,在这里介绍RUNNING和BLOCKED。


Java并发之synchronized深度解析_第1张图片
打断点

说明:在断点的右边打断点,然后点击小虫子按钮,进行Debug调试模式


Java并发之synchronized深度解析_第2张图片
断点选项

说明:选择All意味着JVM,即所有的线程停下来,选择Thread意味着当前的线程停下来,这里我们选择All。
Java并发之synchronized深度解析_第3张图片
选择线程

说明:选择Debugger-Frames-Thread0(方框里面可以选择线程)-选择Thread(Thread是当前线程,Main是主线程)


线程选择

Java并发之synchronized深度解析_第4张图片
查看状态

说明:选择Thread,右击鼠标,选择计算机小白色按钮,在弹出框输入this.getState(),就可以查看查看线程是RUNNING还是BLOCKED
Java并发之synchronized深度解析_第5张图片
image.png

7. 多线程访问同步方法的7种具体情况

条件:以下同步方法指的是非static同步方法。

  • 两个线程同时访问一个对象的同步方法
    线程安全,因为有synchronized关键字修饰且是在一个对象中,可以起到同步的作用。
  • 两个线程访问的是两个对象的同步方法
    当锁对象是this的时候,因为是两个对象,锁对象指的内容会发生变化,这时候不安全。
    当锁对象是类名.class的时候,尽管是两个对象,但是Class对象只有一个,这个时候安全。
  • 两个线程访问的是Synchronized的静态方法
    synchronized+static 不管在几个对象中,线程都是安全的。
  • 同时访问同步方法和非同步方法
    synchronized的作用域是修饰的方法,没有被修饰的方法不能起到同步的作用。
  • 访问同一个对象的不同的普通同步方法
    同步方法的默认锁对象是this,因为是同一个对象,所以线程0先走完两个方法,然后线程1再执行,串行。
  • 同时访问静态synchronized和非静态synchronized方法
    synchronized+static的所对象是Class对象,普通synchronized的锁对象是this,因为锁对象不同,所以两个方法可以并行。
  • 方法抛出异常后,释放锁
    synchronized修饰的方法抛出异常后,锁会释放
  • 总结:
    ① 一把锁只能同时被一个线程获取,没有拿到锁的线程 必须等待。
    ② 每个实例都对应有自己的一把锁,不同实例之间互不影响;例外:锁对象是类名.class以及Synchronized修饰的是static方法的时候,所有对象共用统一把类锁。
    ③ 无论你是方法正常执行完毕或者方法抛出异常,都会释放锁。

8. synchronized的性质

  • 可重入
    • 简介:同一线程的外层函数获得锁之后,内层函数可以直接再次获取该锁。
    • 好处:可避免死锁、提升封装性。
    • 粒度(作用域):线程而非调用。

① 同一个方法是可重入的

public class Main {

    int a = 0;

    public static void main(String[] args) {
        Main main = new Main();
        main.method1();
    }

    private synchronized void method1() {
        System.out.println("a="+a);
        if (a == 0){
            a++;
            method1();
        }
    }

}
a=0
a=1

② 可重入不要求是同一个方法

public class Main {

    public static void main(String[] args) {
        Main main = new Main();
        main.method1();
    }

    private synchronized void method1() {
        System.out.println("我是method1");
        method2();
    }

    private synchronized void method2() {
        System.out.println("我是method2");
    }
}
我是method1
我是method2

③ 可重入不要求是同一个类中的

public class Main extends SuperClass{

    int a = 0;

    public static void main(String[] args) {
        Main main = new Main();
        main.doSomting();
    }

    public synchronized void doSomting(){
        System.out.println("我是子类方法");
        super.doSomthing();
    }

}

class SuperClass{
    public synchronized void doSomthing(){
        System.out.println("我是父类方法");
    }
}
我是子类方法
我是父类方法
  • 不可中断
    一旦这个锁已经被别人获得,如果还想获得,只能选择等待或者阻塞,直到别的线程释放这个锁。如果别人永远不释放锁,那么只能永远等下去。

9. 加锁解锁的实现原理

  • 代码Main.java:
public class Main {

    public static synchronized void m() {

    }

    public static void main(String[] args) {
        synchronized (Main.class){

        }
        m();
    }
}
  • javac Main.class并执行javap -v Main.class 截取部分信息
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: ldc           #2                  // class com/smartisan/Synchronized
         2: dup
         3: astore_1
         4: monitorenter
         5: aload_1
         6: monitorexit
         7: goto          15
        10: astore_2
        11: aload_1
        12: monitorexit
        13: aload_2
        14: athrow
        15: invokestatic  #3                  // Method m:()V
        18: return

  public static synchronized void m();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
    Code:
      stack=0, locals=0, args_size=0
         0: return
      LineNumberTable:
        line 14: 0
}
SourceFile: "Synchronized.java"

说明:以上是代码的字节码信息,不难看出:

  1. 同步在形式上有两种方式完成:

① 同步块的实现使用了monitorenter和moniterexit指令

Synchronization of sequences of instructions is typically used to encode the synchronized block of the Java programming language. The Java Virtual Machine supplies the monitorenter and monitorexit instructions to support such language constructs. Proper implementation of synchronized blocks requires cooperation from a compiler targeting the Java Virtual Machine (§3.14).

同步指令集通常用来实现同步代码块。Java虚拟机的指令集中有monitorenter和monitorexit两条指令来支持synchronized关键字的语义。正确实现synchronized关键字需要Javac编译器与Java虚拟器两者共同协作支持。

② 同步方法依靠修饰符ACC_SYNCHRONIZED完成。

Method-level synchronization is performed implicitly, as part of method invocation and return (§2.11.8). A synchronized method is distinguished in the run-time constant pool's method_info structure (§4.6) by the ACC_SYNCHRONIZED flag, which is checked by the method invocation instructions. When invoking a method for which ACC_SYNCHRONIZED is set, the executing thread enters a monitor, invokes the method itself, and exits the monitor whether the method invocation completes normally or abruptly. During the time the executing thread owns the monitor, no other thread may enter it. If an exception is thrown during invocation of the synchronized method and the synchronized method does not handle the exception, the monitor for the method is automatically exited before the exception is rethrown out of the synchronized method.

方法级的同步是隐式的,即无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池的方法表结构中的ACC_SYNCHRONIZED访问标记得知一个方法是否声明为同步方法。当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程就要求先成功持有监视器(monitor),然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放监视器(monitor)。在方法执行期间,执行线程持有了监视器(monitor),其他任何线程都无法再获取到同一个监视器(monitor)。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那么这个同步方法所持有的监视器(monitor)将在异常抛到同步方法之外时自动释放。

  1. 同步本质上都是通过监视器(monitor)提供支持

任意一个对象都拥有自己的监视器(monitor),当这个对象由同步代码块或者这个对象的同步方法调用时,执行方法的线程必须先获取到该对象的监视器才能进入同步代码块或者同步方法,而没有获取到监视器的线程将会被阻塞在同步代码块和同步方法的入口处,进入BLOCKED状态。

  1. 为什么会有两个monitorexit?
    ① 不管你的代码是否会抛出异常,都会有两个monitorexit:一个monitorexit是正常退出同步时执行,一个monitorexit是抛出异常时monitorenter和monitorexit指令依然可以正确配对执行。
    ② 编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行monitorexit指令。

10. 可重入性质的原理

既然synchronized的实现是通过监视器(mJava并发之synchronized深度解析
onitor)提供支持的,那么我们分别看下monitorenter和monitorexit,理解了两者,我们便可以理解可重入性质的原理:

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.

每个对象都与一个监视器相关联。只有当监视器有所有者时,它才会被锁定。执行monitorenter的线程尝试获得与锁对象关联的监视器的所有权时:

  • 如果与锁对象关联的监视器的条目计数为零,则线程将进入监视器并将其条目计数设置为1。此时这个线程是监视器的所有者。
  • 如果线程已经拥有与锁对象关联的监视器,它将重新进入监视器,并增加其条目计数。
  • 如果一个线程已经拥有与锁对象关联的监视器,则其他线程将一直阻塞,直到监视器的条目计数为零,然后再次尝试获得所有权。

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的线程必须是与锁对象关联的监视器的所有者。
线程会减少与锁对象关联的监视器的条目计数。如果结果是条目计数的值为零,则线程将不再是监视器的所有者。之前被阻止进入监视器的其他线程可尝试去拥有监视器(moniter)

说明:从上述分析不难看出,可重入性质依赖的是加锁次数计算器。

11. Java的内存模型

  • Java的内存模型定义了线程之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程共享变量的副本。Java的内存模型控制线程之间的通信,它决定了一个线程对主存共享变量的写入何时对另一个线程可见。示意图如下:


    Java并发之synchronized深度解析_第6张图片
    JMM
  • 线程A与线程B之间若要通信的话,必须要经历以下两个步骤:
    ① 线程A把线程A本地内存中更新过的共享变量刷新到主内存中去。
    ② 线程B到主内存中读取线程A之前更新过的共享变量。

12. 可见性

可见性,指的是线程之间的可见性,即一个线程修改的状态对另一个线程是可见的,也就是一个线程修改的结果,另一个线程马上可以看到。所以保证了可见性,就可以保证线程的安全性。

13. synchronized线程安全的根本原因

了解了Java的内存模型和线程的可见性,不难得出synchronized的线程安全的根本原因:加锁保证了只有一个线程可以操作主存中的共享变量:当本地内存中的共享变量副本发生变化后,解锁之前会把本地内存中共享变量的值刷新到主存。而当其他线程获取到锁,会去主内存中读取该共享变量的新值。

14. synchronized的缺陷

  • 效率低:锁的释放情况少、试图获得锁时不能设定超时、不能中断一个正在试图获得锁的线程
  • 不够灵活:加锁和释放的时机单一,每个所仅有单一的条件(某个对象),可能是不够的
  • 无法知道是否成功获取到锁

15. 注意点

  • 锁对象不能为空
  • 作用域不宜过大
  • 避免死锁

16. 后续

如果大家喜欢这篇文章,欢迎点赞;
如果想看更多 并发 方面的技术,欢迎关注!

你可能感兴趣的:(Java并发之synchronized深度解析)