synchronized 关键字的作用和原理

在多线程同时读写共享资源时,会造成并发问题。java提供了多种方法解决这一问题,synchronized关键字是其中最常用的方法,也是最简单的。synchronized通过使用互斥锁来锁定共享资源,使得同一时刻,只有一个线程可以访问和修改它,其他线程必须等待。当前线程修改完毕,释放互斥锁后,其他线程才能访问。

本文先介绍多线程并发问题的出现场景,再说明synchronized怎么使用,最后简要分析synchronized的实现原理。

并发问题及其三个场景

当多个线程同时读写内存中同一个变量时,因为操作系统线程调度、内核cache缓存不可见性、指令重排序等原因,会导致并发问题,使执行结果难以预料,具体参考并发问题及其产生的原因

根据变量类型及所处位置的不同,并发问题会出现在以下三种场景中:

  1. 静态变量,多线程访问类的同一实例
  2. 静态变量,多线程访问类的不同实例
  3. 实例成员变量,多线程访问同一实例

静态变量,为类的不同实例对象所共享。当多个线程操作类的实例对象时,会同时操作这个变量,分为两种情况: 1. 多个线程操作类的同一实例,此时添加实例对象级别的锁就可以,如为实例方法添加synchronized; 2. 多个线程操作类的不同实例,此时需要使用类级别的锁才可以,如为静态方法添加synchronized

成员变量,实例对象私有。当多个线程操作同一个实例,且实例的代码块或方法没有加锁时,会同时读写这个变量,出现并发问题。其他情况下,不会有并发问题,如: 1. 每个线程操作一个单独的实例; 2. 多个线程共享一个实例对象,但是操作属性的方法或代码块加了锁。

synchronized的作用

多个线程同时读写共享变量时会造成并发问题,解决这个问题一个容易想到的方法就是控制在同一时间,只有一个线程可以访问共享变量,其他线程需要等待共享变量空闲时才可以访问,java提供了synchronized关键字实现这一功能。

synchronized通过为方法或代码块添加互斥锁,来保证线程安全性。持有相同锁的多个线程,同一时间只有一个线程能够拿到锁并执行锁定的代码块或方法。当他们并发访问被锁修饰的方法或同步代码块时,同一时间只能有一个线程可以获取到锁并访问,其他没有获取到锁的线程只能等待锁被释放后,才可以进入执行。这样就保证了持有同一把锁的不同线程,同一时间只有其中一个可以进入执行。synchronized修饰的方法和代码块,无论是正常执行完毕还是抛出异常,都会释放锁。

synchronized使用的锁有两种:对象锁和类锁。对象锁是使用一个实例对象作为锁,根据锁用的位置分为实例方法锁(默认锁对象为this,即当前实例对象)和同步代码块锁(自己指定锁对象)。类锁是使用类.class作为锁,根据使用位置,分为静态方法锁(默认锁对象为class,即当前类的class)和同步代码块锁(自己指定class对象)。

  • 每个实例都对应有自己的一把锁(this),不同实例之间互不影响;
  • 锁对象是*.class以及synchronized修饰的是static方法的时候,该类的所有对象共用这一把锁;

synchronized的作用主要有三个:

  1. 原子性:确保线程互斥的访问同步代码;
  2. 可见性:保证共享变量的修改能够及时可见,其实是通过Java内存模型中的 “对一个变量unlock操作之前,必须要同步到主内存中;如果对一个变量进行lock操作,则将会清空工作内存中此变量的值,在其他线程使用此变量前,需要重新从主内存中load” 来保证的;
  3. 有序性:有效解决重排序问题,即 “一个unlock操作先行发生于后面对同一个锁的lock操作”;

synchronized的三种使用方式

synchronized有以下3种应用方式:

  • 修饰实例方法:使用当前实例对象作为锁,进入同步代码前要获得当前实例对象的锁。可解决场景1,场景3。
  • 修饰静态方法:使用当前类作为锁,进入同步代码前要获得当前类对象的锁。可解决场景1,场景2。
  • 修饰代码块:自己指定加锁对象,可以是对象锁,也可以是类锁,进入同步代码块前要先获得指定的锁。

作用于实例方法

当多线程访问实例方法,且方法内读写的是同一实例的静态变量或实例成员变量时,可以用synchronized修饰方法来解决。

此时多个线程同时持有当前对象作为锁,synchronized会把整个方法转化为同步代码,同一时间只能有一个线程可以持有实例对象的锁,并进入方法执行。在当前执行线程执行完毕释放锁之前,其他线程会被阻塞,不能进入方法执行,保证了线程安全。

注意:使用场景必须是多个线程操作同一实例。如果多个线程操作不同实例对象,对实例成员变量来讲,不会有线程安全问题,而静态变量来讲,不同线程的对象锁是不同的,不同线程仍可并发执行,不能保证线程安全。

public class SynchronizedExample implements Runnable {
    static int m = 0;
    int n = 0;

    public synchronized void inc() {
        // 自加操作++实际分为3个指令,不是原子操作,因此多线程同时读写会有并发问题
        m++;
        n++;
    }

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

    public static void main(String[] args) throws InterruptedException {
       // 多线程共享同一实例
       ProblemExample task = new ProblemExample();
       Thread t1 = new Thread(task);
       Thread t2 = new Thread(task);
       t1.start();
       t2.start();
       t1.join();
       t2.join();
       System.out.println("finish m: " + m + ", n: " + task.n);
       // finish m: 20000, n: 20000
    }
}
复制代码

作用于静态方法

当多线程同时读写一个类的静态变量时,会有并发问题。

注意!当多线程各自使用类的不同实例但是用各自使用的实例对象来加锁时,不能保证线程安全。

如果多个线程访问的是类的同一实例对象,此时可以用这个实例对象作为锁,也可以用类对象ClassName.class作为锁。

如果多个线程访问的是类的不同实例对象,此时,只能使用类对象ClassName.class作为锁,不能使用实例对象作为锁。

public class SynchronizedExample implements Runnable {
    static int m = 0;

    public static synchronized void inc() {
        // 自加操作++实际分为3个指令,不是原子操作,因此多线程同时读写会有并发问题
        m++;
    }

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

    public static void main(String[] args) throws InterruptedException {
       // 多线程共享不同实例,只能用类对象作为锁
       ProblemExample task1 = new ProblemExample();
       Thread t1 = new Thread(task1);
       ProblemExample task2 = new ProblemExample();
       Thread t2 = new Thread(task2);
       t1.start();
       t2.start();
       t1.join();
       t2.join();
       System.out.println("finish m: " + m);
       // finish m: 20000
    }
}
复制代码

作用于代码块

synchronized除了作用于实例方法和静态方法,还可以作用于方法内部的代码块。此时锁需要自己指定,既可以是实例对象,也可以是类对象。

public class SynchronizedExample implements Runnable {
    static SynchronizedExample instance = new SynchronizedExample();
    static int i = 0;

    @Override
    public void run() {
        // 使用同步代码块对变量i进行同步操作,锁对象为instance
        synchronized(instance) {
            for (int j = 0; j < 1000000; j++) {
                i++;
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(instance);
        Thread t2 = new Thread(instance);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
}
复制代码

synchronized作用于一个给定的实例对象instance, 即当前实例对象就是锁对象,每次当线程进入synchronized包裹的代码块时,就会要求当前线程持有instance实例对象锁,如果当前有其他线程正持有该对象锁,那么新到的线程就必须等待,这样就保证了每次只有一个线程执行i++操作。当然,还可以使用this或者class

// this,当前实例对象锁
synchronized(this) {
    for (int j = 0; j < 1000000; j++) {
        i++;
    }
}

// class对象锁
synchronized(SynchronizedExample.class) {
    for (int j=0; j<1000000; j++) {
        i++;
    }
}
复制代码

介绍完synchronized的基本含义和使用方式后,下面进一步分析synchronized的实现原理。

原理简介

同步方法和同步代码块的底层实现原理不同。

同步方法,相较于普通方法,在方法内部的常量池中添加了ACC_SYNCHRONIZED标识符。当方法调用时,调用指令会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果被设置了,执行线程将先获取monitor,获取成功后才能执行方法体,方法执行完成后再释放monitor。在方法执行期间,其他任务线程都无法再获取同一个monitor对象。

同步代码块通过monitorentermonitorexit指令实现线程互斥。

  • monitorenter:线程执行monitorenter指令时尝试获取monitor的所有权,如果monitor的进入数是0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1。如果其他线程占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
  • monitorexit:执行monitorexit的线程必须是当前锁对象对应的monitor的所有者,指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

可重入性

synchronized 是可重入的,当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功。因此在一个线程调用 synchronized 方法的同时在其方法体内部调用该对象另一个 synchronized 方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是 synchronized 的可重入性。

参考

Java基础之Synchronized原理
关键字: synchronized详解
深入分析Synchronized原理(阿里面试题)

Guess you like

Origin juejin.im/post/7054827107880271908