多线程 -锁机制 - Synchronized

使用多线程主要是为了提高系统资源利用率,但由于多线程之间是并发执行,且系统调度又是随机的,多线程环境下业务中一般会配置多个线程执行相同的代码,如果在此段代码中存在共享变量或一些组合操作,则不可避免会引起线程间数据错乱等安全问题。

一 线程安全

当多个线程访问某一个类(对象或方法)时,这个类始终都能表现出正确的行为,那么这个类(对象或方法)就是线程安全的。

此时就需要使用各种锁机制来保证线程安全且程序按照我们的意愿执行;

Java多线程加锁主要有两种:

1、synchronized;

2、显式Lock;

二 synchronized

synchronized是Java的一个关键字,其修饰的代码相当于数据库的互斥锁。它能够将代码块锁起来,确保同一时刻只有一个线程处于方法或同步块中,确保线程对变量访问的可见和排它,获得锁的对象在代码结束后,会对锁进行释放。

synchronized是一种互斥锁,一次只允许一个线程进入被锁的方法块;

2.1 作用

其主要有三个作用:

    1、原子性:同一时间只有一个线程可以访问代码块;

    2、有序性:对于同一个锁操作,unlock必定发生于lock之前;

    3、可见性:对于同一共享变量的修改对其他线程及时可见;

2.2 使用场景

public class Synchronized {

    // 修饰代码块
    public void test(int i) {
        Long test = 0L;
        synchronized (test) {
            i++;
        }
    }

    // 修饰普通方法
    public synchronized void test1() {
        System.out.println();
    }

    // 修饰静态方法
    public synchronized static void test2() {
        System.out.println();
    }

    // 修饰静态全局变量
    private static Long i;
    public void test3() {
        synchronized (i) {
            System.out.println();
        }
    }
}

1、修饰普通方法或代码块:此种场景实际是对调用该方法的对象 (即当前类的当前实例对象) 加锁,称为 “对象锁“ 或 ”方法锁“;

2、修饰静态方法或静态变量:此种场景实际是对当前Class本身(类的字节码文件对象,不是实例对象)加锁,称为“类锁”;

此处需要注意的是获取类锁的线程与获取对象锁的线程并不冲突,示例:

public class Synchronized {

    public synchronized static void staticLock() {
        for (int i = 0; i < 10; i++) {
            System.out.println("staticLock execute ----- " + i);
        }
    }

    public void normalMethod() {
        for (int i = 0; i < 10; i++) {
            System.out.println("nromalMethod execute ----- " + i);
        }
    }

    public static void main(String[] args) {
        Synchronized demo = new Synchronized();

        Thread t1 = new Thread(() -> {
            demo.normalMethod();
        });

        Thread t2 = new Thread(() -> {
            staticLock();
        });

        t1.start();
        t2.start();
    }
}

输出结果:

2.3 synchronized原理

java中每一个对象都有一个内置锁(监视器锁 monitor),而synchronized就是使用对象的内置锁来锁定对象。

2.3.1 同步代码块

同样先来个示例:

public class Synchronized {
    public void test() {
        Long i = 1L;
        synchronized (i) {
            System.out.println();
        }
    }

通过 javap -c 指令反编译上述class文件看下:

如上可知,如果对代码块加了 synchronized,则在加锁代码逻辑前后会加入 monitorenter 和 monitorexit 指令,

monitorenter大致流程:

    1、monitor进入次数为0,则成功获取锁,设置进入次数为1,执行业务; 

    2、如果是当前线程再次进入获取synchronized锁对象时,判断是否是同一线程,是则monitor进入次数加1(可重入锁);

    3、如果此时其他线程A进入获取当前synchronized锁对象,判断monitor进入次数不为0且不为线程A持有,则线程A阻塞,自旋等待锁,monitor进入次数降为0后,线程A再次抢锁(锁升级);

monitorexit流程:

    当前持有monitor的线程调用monitorexit,指令执行,monitor进入次数减一,如果减一后monitor进入次数为0,则当前线程释放锁,其他等待锁资源的线程可以尝试抢锁;

2.3.2 同步方法

与 同步代码块 方法加锁方式不同,同步方法 加锁基于 ACC_SYNCHRONIZED 标示符实现,当同步方法被调用时,会先检查方法有没有设置 ACC_SYNCHRONIZED 标识符,如果有,线程则先获取monitor,获取成功之后才能执行方法,方法执行完后再释放monitor;

如果是实例方法,JVM 会尝试获取实例对象的锁,如果是类方法,JVM 会尝试获取类锁。在同步方法完成以后,不管是正常返回还是异常返回,都会释放锁。

此处不再展开,有兴趣的朋友可自行尝试;

2.4 锁升级

上面提到,synchronized通过n内置监视器锁(monitor)实现,而监视器锁本质依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。考虑到操作系统实现线程切换需要从用户态转换到核心态,状态之间的转换需要较长时间,这个成本是非常高的,这也是为什么Synchronized效率低的原因。这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”。

JDK1.6版本之前没有锁分级的概念,synchronized是重量级锁。

但在JDK1.6版本之后增加了CAS自旋、锁消除,锁粗化,另外还引入了 无锁、偏向锁、轻量级锁、重量级锁 的锁分级状态,根据锁竞争情况的不同,锁状态逐渐升级,性能逐渐递减,但在大部分情况下业务并发情况不高,偏向锁 和 轻量级锁 就能满足我们的要求;

JDK 1.6中默认是开启偏向锁和轻量级锁的,可以通过 -XX:-UseBiasedLocking 来禁用偏向锁。

锁可以升级,但不能降级;

2.4.1 偏向锁

核心思想:假设加锁的代码从始至终只有一个线程在调用,如果有多于一个线程调用,再升级为轻量级锁;

也就是说偏向锁只适用于单线程执行代码块的流程,如果是多线程,偏向锁一定至少会升级为轻量级锁;所以需要根据实际业务情况,使用  -XX:-UseBiasedLocking 决定是否需要禁用偏向锁;

偏向锁升级:

1、线程A获取到锁,可以进入同步代码块,虚拟机会在当前锁对象头和栈帧中记录偏向的锁的 threadId, 即设置为偏向模式;同时使用CAS操作把获取到这个锁的线程ID记录在对象的 Mark Word 中;

2、线程A再次获取锁,判断当前线程的 threadId 和锁对象头中的 threadId 是否一致,如果一致,表示是同一线程,则无需使用CAS加、解锁(CAS操作会延迟本地调用);

3、此时线程B开始获取锁,判断当前线程的 threadId 和锁对象头中的 threadId 不一致,则查看锁对象中设置的 threadId 对应的线程是否存活:

  1.     如果存活:判断线程A是否仍需要持有锁对象,如果需要,则暂停线程A,撤销偏向锁,升级为轻量级锁;如果线程A不再需要持有锁对象,则将锁对象状态设置为无锁状态;
  2.     如果没有存活:重置锁对象为无锁状态,线程B可以竞争将其设置为偏向锁;

2.4.2 轻量级锁

核心思想:被枷锁的代码不会发生并发,在没有多线程竞争的情况下,减少重量级中操作状态转换导致的性能消耗;

当关闭偏向锁或多线程竞争偏向锁导致偏向锁升级为轻量级锁时会尝试获取轻量级锁,其步骤如下:

1、线程A调用同步代码块,判断锁对象状态,如果对象没有被锁定(锁标志位为“01”状态,是否为偏向锁为“0”),JVM首先在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间。

2、拷贝对象头中Mark Word的拷贝(即Displaced Mark Word)至步骤一建立的锁记录空间中,然后JVM使用CAS尝试将对象的Mark Word更新为指向锁记录(Lock Record)的指针。

3、如果更新成功,则线程A拥有当前对象锁,将对象Mark Word的锁标志位设置为“00”(轻量级锁);

4、如果更新失败,JVM检查对象Mark Word中的Lock Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,可以直接进入同步块继续执行。如果不是存在锁竞争(eg:线程B先拿到锁),线程A自旋等待线程B释放锁,如果线程A自旋结束或线程A自旋过程中线程C也来竞争锁,则轻量级锁升级为重量级锁,重量级锁会阻塞除了拥有锁的线程外的其他线程,防止CPU空转。

2.4.3 对比

优点 缺点 适用场景
偏向锁 加解锁无额外消耗,性能和执行非同步方法相比几乎无差别 存在锁竞争时会带来额外的锁撤销消耗 适用于大部分情况下都是单一线程访问方法块的场景
轻量级锁 不阻塞竞争线程,提高程序响应速度 锁等待时自旋,导致CPU空转消耗性能 适用于线程锁竞争较少的场景,提高响应速度
重量级锁 不适用自旋,不会导致CPU空转 线程阻塞,响应时间长 适用于多线程竞争锁的场景,追求吞吐量

2.4.4 锁粗化

将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,避免频繁加解锁导致的性能消耗;

2.4.5 锁消除

JVM在即时编译阶段,通过对运行上下文的扫描,经过逃逸分析,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省冗余的请求锁导致的性能消耗,示例:

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            StringBuffer sb = new StringBuffer();
            sb.append("concat1").append("concat2");
        }
    }

再来看下StringBuffer.append 方法源码:

    @Override
    public synchronized StringBuffer append(String str) {
        toStringCache = null;
        super.append(str);
        return this;
    }

如上示例,StringBuffer的作用域仅在main方法中,没有逃逸到main方法之外,JVM在编译时检测到此种场景时就会将消除调用 append 方法需要的 synchronized,减少不必要的性能消耗;

开启支持锁消除

将锁消除需要程序必须运行在server模式(server模式会比client模式作更多的优化), 通过 -XX:+DoEscapeAnalysis 开启逃逸分析, 通过 -XX:+EliminateLocks 开启锁消除;

eg: -server -XX:+DoEscapeAnalysis -XX:+EliminateLocks

猜你喜欢

转载自blog.csdn.net/sxg0205/article/details/108399176