后端精进笔记06: 线程中的synchronized锁

198 阅读7分钟

一、锁概述

1.1 锁的分类

这一小节说的锁指的是锁的思想,并不是实际的锁对象。常见的锁又如下几种:

1.1.1自旋锁:

在循环语句中使用CAS技术尝试更新数据,执行失败则一直在自我循环,直到成功为止,故称为自旋锁

1.1.2 悲观锁、乐观锁

  • 悲观锁:默认其他操作(线程)更改当前操作(线程)的目标对象,当前操作(线程)会从读数据开始就会直接锁住目标对象,直到操作完成(synchronized锁是典型的悲观锁)。
  • 乐观锁:默认其他操作(线程)不会更改当前操作(线程)的目标对象,在执行写操作时,如果现有数据与自己持有的就数据一致,则执行写操作,否则操作失败。(执行sql update时常用)

1.1.3 独享锁(写锁)、共享锁(读锁)

  • 独享锁(写锁):也叫排他锁,是指该锁一次只能被一个线程锁持有。如果线程T对数据A加上排他锁后,则其他线程不能再对A加任何类型的锁。获得排他锁的线程既能读数据又能修改数据(synchronized和JUC中Lock的实现类就是互斥锁)。
  • 共享锁(读锁):指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排他锁。获得共享锁的线程只能读数据,不能修改数据。

1.1.4 可重入锁、不可重入锁:

这里两者的区别在于,在已获取当前锁的情况下,如果能自由进入依旧由当前锁加锁的代码而无需再次获取锁,则为可重入锁。

1.1.5 公平锁、非公平锁:

这里两者的区别在于是否顺序获取锁,后来的线程却先获取来锁,则是非公平锁。

1.2 synchronized关键字

Java中的每个对象都与一个监视器对象相关联,有且只有一个线程能锁定或解锁。任何尝试锁定该监视器的其他线程都会被阻塞,直到获取该监视器的锁。

synchronized关键字还能保证变量的可见性(读写操作都是操作的住内存)。

特性:

  • 是悲观锁
  • 是独占锁(排他锁)
  • 是可重入锁

在使用synchronized锁时,若未指明锁对象,则默认持有this(当前对象)当作锁对象。⚠️注意:对于同一操作,不同线程之间一定要持有同一个锁对象,加锁操作才有意义:

1.2.1 持有不同synchronized锁对象

public class Demo311 {

    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (this) {
                    demoMethod();
                }
            }
        }).start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (this) {
                    demoMethod();
                }
            }
        }).start();

    }

    private static void demoMethod() {
        System.err.println(Thread.currentThread().getName()+" 正在读取数据……");
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.err.println(Thread.currentThread().getName()+" 正在写入数据……");
    }
}

可以看到,两个线程在交替执行,执行结果:

Thread-0 正在读取数据……
Thread-1 正在读取数据……
Thread-0 正在写入数据……
Thread-1 正在写入数据……

1.2.2 持有相同锁对象

锁对象可以是一个共享的obj、或字节码(class)对象。

public class Demo311 {

    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (Demo311.class) {
                    demoMethod();
                }
            }
        }).start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (Demo311.class) {
                    demoMethod();
                }
            }
        }).start();

    }

    private static void demoMethod() {
        System.err.println(Thread.currentThread().getName()+" 正在读取数据……");
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.err.println(Thread.currentThread().getName()+" 正在写入数据……");
    }
}

可以看到,等当前现在执行完毕,另一个线程才开始执行

Thread-0 正在读取数据……
Thread-0 正在写入数据……
Thread-1 正在读取数据……
Thread-1 正在写入数据……

1.3 锁消除与锁粗化

锁消除与锁粗化是JVM层面针对锁做的优化,无需硬编码。

1.3.1 锁消除

锁削除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行削除。

1.3.2 锁粗化

如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部(由多次加锁编程只加锁一次)。

通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽可能短,但是大某些情况下,一个程序对同一个锁不间断、高频地请求、同步与释放,会消耗掉一定的系统资源,因为锁的讲求、同步与释放本身会带来性能损耗,这样高频的锁请求就反而不利于系统性能的优化了,虽然单次同步操作的时间可能很短。

锁粗化就是告诉我们任何事情都有个度,有些情况下我们反而希望把很多次锁的请求合并成一个请求,以降低短时间内大量锁请求、同步、释放带来的性能损耗。

二、深入理解synchronized关键字

2.1 synchronized加锁的原理

2.1.1 对象头

对象在提交到JVM中时,需要在对象中添加一些标记字段用于增强对象功能,这些标记字段组成了对象头。一个对象头的基本包含如下几个部分:

  • mark word:存储对象自身的运行时数据,如hashcode、gc分代年龄、锁标志位等。也是实现synchronized加锁的核心部分。
  • class matedate address:指向类的元数据地址(JVM据此确定当前对象属于哪个类)
  • array length(数组长度,仅数组对象拥有)

2.1.2 加锁原理

  • 未加锁的对象中,对象头的mark word中存储的是hashcode、gc分代年龄、锁标志位等信息,无锁状态下的锁标志位为01

    00

  • 线程在尝试获取锁时,使用CAS机制改写锁对象中对象头的mark word信息,改写成功则获取到锁(锁标志位改为00,加锁成功)。会在当前线程中的栈空间空开辟一块空间,存储对象的hashcode、gc分代年龄、锁标志位等信息,而锁对象的请求头中存储的是线程中存储原对象头信息的内存地址。其他线程过来获取锁对象时,发现锁对象已被其他线程持有,则进入阻塞状态。而解锁的过程就是逆向回写到锁对象的mark word中:

    42

2.2 synchronized锁的升级

完整的mark word中会存储如下的某一种信息,表示加锁/未加锁/GC回收:

59

2.2.1 偏向锁到轻量级锁

偏向锁是JDK为我们提供的synchronized锁优化,JDK认为,大多数场景下时同一个线程在对同步代码块执行加锁解锁操作,这样的操作可以只为其提供偏向锁。

偏向锁是默认开启的,线程A第一次获取锁时,锁对象的mark word字段会记录对应线程A的id,如果解锁后,下次还是同一个线程A来获取锁对象,则无需获取锁对象而是直接执行,这样就减小了加锁解锁的性能消耗。偏向锁的本质就是无锁

但是如果出现了另一个线程B来尝试获取锁对象,则自动由偏向锁升级为轻量级锁:开辟线程的栈空间,存入锁对象中对象头的mark word信息,回写对应的内存地址。如果获取锁失败,则使用CAS机制循环尝试获取锁对象。

55

2.2.2 重量级锁

如果一直未能获取锁对象,CAS尝试次数过多(次数可通过参数配置),则轻量级锁将会升级为重量级锁,线程也会进入阻塞状态。

43