栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > Java

【Java多线程】内置锁(Synchronized)的前世今生

Java 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

【Java多线程】内置锁(Synchronized)的前世今生

文章目录
  • 一.前言
    • 了解高并发必须知道的概念
    • 了解锁的概念
    • 什么互斥和同步
    • 什么是互斥量
    • 线程安全三大特性
  • 二.为什么要用锁?
  • 三.什么是内置锁
  • 四.synchronized使用
      • 1.线程安全问题产生
      • 2.初识Synchronized
      • 3.错误使用Synchronized的案例
        • 3.1.案例1
        • 3.2.案例2
        • 3.3.案例3
        • 3.4.小结
  • 五.Jvm对synchronized的优化
    • 1.Java对象内存结构
    • 2.JDK1.6中JVM对Synchronized的优化
      • 2.1.锁消除(Lock Elimination)
      • 2.2.锁粗化(Lock Coarsening)
      • 2.3.适应性自旋锁(Adaptive Spinning)
      • 2.4.简述偏向锁
      • 2.5.简述轻量级锁
      • 2.6.简述重量级锁
    • 3.锁升级
      • 3.1.什么是锁升级
      • 3.2.锁升级的四种锁状态的思路及特点
        • 1.无锁状态
        • 2.偏向锁状态
        • 3.轻量级锁状态
        • 4.重量级锁状态
    • 4.加锁和解锁的过程
      • 4.1.加锁的过程
      • 4.2.解锁的过程
    • 5.锁的优缺点
    • 5.总结

一.前言

并发编程最佳学习路线
【Java基础】多线程从入门到掌握
【Java多线程】线程通信

了解高并发必须知道的概念

【Java多线程】高并发修炼基础之高并发必须了解的概念

了解锁的概念

【Java多线程】成神之路中必须要了解的锁分类

什么互斥和同步
  • 互斥是指某一资源同一时间只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法控制对资源的访问顺序
  • 同步是指在互斥的基础上实现对资源的有序访问,即:也是不可以同时访问,并且还需要按照某种顺序来运行。

什么是互斥量

互斥量mutex

  • 是Linux提供一把互斥锁mutex(也称之为互斥量)
  • 用于对共享资源加锁,保证一时间只允许一个线程对其进行访问
线程安全三大特性

【Java多线程】重温并发BUG的源头之可见性、原子性、有序性

二.为什么要用锁?
  • 锁可以解决并发执行任务执行过程中对共享数据顺序访问、修改的场景。比如对同时对一个账户进行扣款或者转账。
三.什么是内置锁

Java内置锁不需要显式的获取锁和释放锁,由JVM内部来实现锁的获取与释放。而且任何一个对象都能作为一把内置锁。在JDK1.4及之前就是使用内置锁Synchronized来进行线程同步控制的

上文说,任何一个对象都能作为一把内置锁”,意味着synchronized关键字出现的地方,都有一个对象与之关联,具体表现为:

  • 当synchronized作用于普通方法时,锁对象是this;
  • 当synchronized作用于静态方法时,锁对象是当前类的Class对象;
  • 当synchronized作用于代码块时,锁对象是synchronized(obj)中的这个obj

原理

  • 由 JVM 虚拟机内部实现,是基于 monitor 机制,每个对象都存在着一个 监视器(monitor)实与之关联,monitor的本质是依赖于底层操作系统的互斥量实现,称为内部锁或者Monitor锁。

    Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

优缺点:

  • 优点: 即内置锁的特性,不需要使用者显示的获取锁和释放锁

  • 缺点:线程拿不到锁就会一直等待,除了获取锁没有其他办法能够让其结束等待

  • 详情情况我的这篇文章【Java多线程】了解线程的锁池和等待池概念 已经写清楚了,偷个懒,就不重复造轮子了。

四.synchronized使用

当多个线程同时运行时,线程的调度由操作系统决定,程序本身无法决定。因此,任何一个线程都有可能在任何指令处被操作系统暂停,然后在某个时间段后继续执行。这个时候,有个单线程场景下不存在的问题就来了:如果多个线程时读写共享变量,会出现数据不一致的问题。


1.线程安全问题产生
public class SyncTest1 {
    public static void main(String[] args) throws Exception {
        AddThread add = new AddThread();
        DecThread dec = new DecThread();
        add.start();
        dec.start();
        add.join();
        dec.join();
        System.out.println(Counter.count);
    }
}

//计数器
class Counter {
    public static int count = 0;
}
//自增线程
class AddThread extends Thread {
    public void run() {
        for (int i=0; i<10000; i++) { Counter.count += 1; }
    }
}
//自减线程
class DecThread extends Thread {
    public void run() {
        for (int i=0; i<10000; i++) { Counter.count -= 1; }
    }
}

上面的代码两个线程同时对一个int变量进行操作,一个加10000次,一个减10000次,最后结果应该是0,但是,每次运行,结果实际上都是不一样的。

连续执行三次结果



这是因为对变量进行读取和写入时,结果要正确,必须保证是原子操作。原子操作是指不能被中断的一个或一系列操作。


实际上执行n = n + 1并不是一个原子操作,它的执行过程如下:

  1. 从主存中读取变量x副本到工作内存
  2. 给x加1
  3. 将x加1后的值写回主存

我们假设n的值是100,如果两个线程同时执行n = n + 1,得到的结果很可能不是102,而是101,
原因在于:多个线程执行时,CPU对线程的调度是随机的,我们不知道当前程序被执行到哪步就切换到了下一个线程

  • 如果线程1在从主内存将n=100的值同步到工作内存时,此时cpu切换到线程2,线程2也将n=100的值同步到工作内存
  • 线程1n+=1 = 101,然后同步到主内存此时主内存为101
  • 线程2 n-=1 = 99,然后同步到主内存此时主内存为99
  • 显然由于执行顺序的不同n最终的结果可能为101也可能为99

这说明多线程场景下,要保证逻辑正确,即某一个线程对共享变量进行读写时,其他线程必须等待


2.初识Synchronized
  • 通过加锁和解锁的操作,就能保证在一个线程执行期间,不会有其他线程会进入此代码块。

  • 即使在执行期线程被操作系统中断执行,其他线程也会因为无法获得锁导致无法进入此代码块。只有执行线程将锁释放后,其他线程才有机会获得锁并执行。这种加锁和解锁之间的代码块我们称之为临界区(Critical Section),任何时候临界区最多只有一个线程能执行。

  • Java使用synchronized关键字对一个对象进行加锁、解锁,以保证操作的原子性。


如何使用Synchronized

  1. 找出修改共享变量的线程代码块;
  2. 选择一个共享实例作为锁;
  3. 使用synchronized(lockObject) { }.
  • 在使用synchronized的时候,不必担心抛出异常。因为无论是否有异常,都会在synchronized结束处正确释放锁:
public void add(int m) {
    synchronized (obj) {
        if (m < 0) {
            throw new RuntimeException();
        }
        this.value += m;
    } // 无论有无异常,都会在此释放锁
}

使用synchronized优化SyncTest1案例中线程不安全问题

public class SyncTest2 {
    public static void main(String[] args) throws Exception {
        AddThread add = new AddThread();
        DecThread dec = new DecThread();
        add.start();
        dec.start();
        add.join();
        dec.join();
        System.out.println(Counter.count);
    }
}
//计数器
class Counter {
    public static final Object lock = new Object();
    public static int count = 0;
}
//自增线程
class AddThread extends Thread {
    public void run() {
        for (int i=0; i<10000; i++) {    synchronized(Counter.lock) { Counter.count += 1;} }
    }
}
//自减线程
class DecThread extends Thread {
    public void run() {
        for (int i=0; i<10000; i++) {  synchronized(Counter.lock){ Counter.count -= 1;} }
    }
}

执行结果

代码

 synchronized(Counter.lock) {//获取锁
 
  }//释放锁
  • 它表示用Counter.lock实例作为锁,2个线程在执行各自的synchronized(Counter.lock) { ... }代码块时,必须先获得锁,才能进入代码块进行。 执行结束后,在synchronized语句块结束会自动释放锁。 这将会导致对Counter.count变量进行读写就不能同时进行。无论运行多少次,最终结果都是0。

synchronized解决了多线程同步访问共享变量的有序性问题。 但它的缺点是带来了性能下降。因为synchronized代码块无法并发执行。此外加锁和解锁需要消耗一定的时间,所以,synchronized会降低程序的执行效率。


3.错误使用Synchronized的案例 3.1.案例1
public class Main {
    public static void main(String[] args) throws Exception {
        AddThread add = new AddThread();
        DecThread dec = new DecThread();
        add.start();
        dec.start();
        add.join();
        dec.join();
        System.out.println(Counter.count);
    }
}

class Counter {
    public static final Object lock1 = new Object();
    public static final Object lock2 = new Object();
    public static int count = 0;
}

class AddThread extends Thread {
    public void run() {
        for (int i=0; i<10000; i++) {
            synchronized(Counter.lock1) {
                Counter.count += 1;
            }
        }
    }
}

class DecThread extends Thread {
    public void run() {
        for (int i=0; i<10000; i++) {
            synchronized(Counter.lock2) {
                Counter.count -= 1;
            }
        }
    }
}

执行结果

结果并不是0,这是因为2个线程各自的synchronized锁住的不是同一个对象! 这使得2个线程各自都可以同时获得锁:因为JVM只保证同一个锁在任意时刻只能被一个线程获取,但两个不同的锁在同一时刻可以被2个线程分别获取。 使用synchronized的时候,获取到的是哪个锁非常重要。锁对象如果不对,代码逻辑就不对。

3.2.案例2
public class SyncTest3 {
    public static void main(String[] args) throws Exception {
        Thread [] ts = new Thread[] { new AddStudentThread(), new DecStudentThread(), new AddTeacherThread(), new DecTeacherThread() };
        for (Thread t : ts) {
            t.start();
        }
        for (Thread t : ts) {
            t.join();
        }
        System.out.println(Counter.studentCount);
        System.out.println(Counter.teacherCount);
    }
}

class Counter {
    public static final Object lock = new Object();
    public static int studentCount = 0;
    public static int teacherCount = 0;
}

class AddStudentThread extends Thread {
    public void run() {
        for (int i=0; i<10000; i++) {
            synchronized(Counter.lock) {
                Counter.studentCount += 1;
            }
        }
    }
}

class DecStudentThread extends Thread {
    public void run() {
        for (int i=0; i<10000; i++) {
            synchronized(Counter.lock) {
                Counter.studentCount -= 1;
            }
        }
    }
}

class AddTeacherThread extends Thread {
    public void run() {
        for (int i=0; i<10000; i++) {
            synchronized(Counter.lock) {
                Counter.teacherCount += 1;
            }
        }
    }
}

class DecTeacherThread extends Thread {
    public void run() {
        for (int i=0; i<10000; i++) {
            synchronized(Counter.lock) {
                Counter.teacherCount -= 1;
            }
        }
    }
}

执行结果

  • 上面4个线程对两个共享变量分别进行读写操作,但是使用的锁都是Counter.lock对象,这就造成了原本可以并发执行的Counter.studentCount += 1和Counter.teacherCount += 1无法并发执行了,执行效率大大降低。
  • 实际上,需要同步的线程可以分成2组:AddStudentThread和DecStudentThread,AddTeacherThread和DecTeacherThread,组之间不存在竞争关系,因此,应该使用2个不同的锁
public class SyncMultiTest3 {
    public static void main(String[] args) throws Exception {
        //创建线程
        Thread[] ts = new Thread[]{new AddStudentThread(), new DecStudentThread(), new AddTeacherThread(), new DecTeacherThread()};
        //启动线程
        for (Thread t : ts) {
            t.start();
        }
        //优先子线程先执行
        for (Thread t : ts) {
            t.join();
        }
        //最后打印执行结果
        System.out.println(Counter.studentCount);
        System.out.println(Counter.teacherCount);
    }
}

//计数器
class Counter {
    public static final Object lockTeacher = new Object();//学生线程锁对象
    public static final Object lockStudent = new Object();//老师线程锁对象
    public static int studentCount = 0;
    public static int teacherCount = 0;
}

//增加学生数量线程
class AddStudentThread extends Thread {
    public void run() {
        for (int i = 0; i < 10000; i++) {
            synchronized (Counter.lockStudent) {
                Counter.studentCount += 1;
            }
        }
    }
}

//减少学生数量线程
class DecStudentThread extends Thread {
    public void run() {
        for (int i = 0; i < 10000; i++) {
            synchronized (Counter.lockStudent) {
                Counter.studentCount -= 1;
            }
        }
    }
}

//增加老师数量线程
class AddTeacherThread extends Thread {
    public void run() {
        for (int i = 0; i < 10000; i++) {
            synchronized (Counter.lockTeacher) {
                Counter.teacherCount += 1;
            }
        }
    }
}

//减少老师数量线程
class DecTeacherThread extends Thread {
    public void run() {
        for (int i = 0; i < 10000; i++) {
            synchronized (Counter.lockTeacher) {
                Counter.teacherCount -= 1;
            }
        }
    }
}

执行结果

3.3.案例3

JVM规范定义了几种原子操作:

  • 基本类型(long和double除外)赋值,例如:int n = m;
  • 引用类型赋值,例如:List list = anotherList。
    • long和double是64位数据,JVM没有明确规定64位赋值操作是不是一个原子操作,不过在x64平台的JVM是把long和double的赋值作为原子操作实现的。

  1. 单条原子操作的语句不需要同步。例如:
public void set(int m) {
    synchronized(lock) {
        this.value = m;
    }
}

就不需要同步

//引用类型赋值
public void set(String s) {
    this.value = s;
}
  1. 如果是多行赋值语句,就必须保证是同步操作
class Pair {
    int first;
    int last;
    public void set(int first, int last) {
        synchronized(this) {
            this.first = first;
            this.last = last;
        }
    }
}

有些时候,通过一些巧妙的转换,可以把非原子操作变为原子操作。例如,上述代码如果改造成:

class Pair {
    int[] pair;
    public void set(int first, int last) {
        int[] ps = new int[] { first, last };
        this.pair = ps;
    }
}

就不再需要同步,因为this.pair = ps是引用赋值的原子操作。而语句:int[] ps = new int[] { first, last };,这里的ps是方法内部定义的局部变量,每个线程都会有各自的局部变量,互不影响,并且互不可见,并不需要同步。

3.4.小结
  1. 多线程同时读写共享变量时,会造成逻辑错误,因此需要通过synchronized同步;
  2. 同步的本质就是给指定对象加锁,加锁后才能继续执行后续代码
  3. 注意加锁对象必须是同一个实例;
  4. JVM定义的单个原子操作不需要同步
五.Jvm对synchronized的优化

在 JDK1.6 之前, syncronized 是一把重量级锁,在 JDK 1.6之后为了减少获得锁和释放锁带来的性能消耗,会有一个锁升级的过程,给Synchronized加入了 "偏向锁、自旋锁、轻量级锁"的特性,这些优化使得Synchronized的性能在某些场景下与ReentrantLock的性能持平

  1. syncronized一共有4种锁状态,级别从低到高依次是:无锁->偏向锁->轻量级锁->重量级锁,这几个状态会随着竞争情况逐渐升级。
  2. 锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
1.Java对象内存结构

对象在堆内存中存储的布局分为3块


如上图所示,以Hotspot虚拟机为例, 在实例化一个对象后,在 Java内存中的布局 可分为3块:

1.对象头包括2部分(ObjectHeader):

  • 对象自身的运行时数据(MarkWord 标记字段 )

    • 存储对象hashCode、对象GC分代年龄、锁类型标记、偏向锁线程 ID 、 CAS 锁指向线程 LockRecord 的指针等, synconized 锁的机制与这个部分( MarkWord )密切相关,用 MarkWord 中最低的三位代表锁的状态,其中一位是偏向锁位,另外两位是普通锁位。

    在运行期间,Mark Word里面存储的数据会随着锁标志位的变化而变化。MarkWord可能变为存储以下5种数据,如下图所示

    可以看到

    • 当对象状态为偏向锁时,Mark Word存储的是偏向的线程ID;
    • 当对象状态为轻量级锁时,Mark Word存储的是指向线程栈中Lock Record(锁记录)的指针
    • 当对象状态为重量级锁时,Mark Word为指向堆中的monitor对象的指针
  • 对象类型指针( ClassPointer)

    • 对象指向它的类元数据的指针、 JVM 就是通过它来确定是哪个 Class 的实例。

2.实例数据区域(InstanceData)

  • 此处存储的是对象真正有效的信息,比如对象中所有变量的内容,,其大小由各个变量的大小决定,比如:byte和boolean是1个字节,short和char是2个字节,int和float是4个字节,long和double是8个字节,reference是4个字节

3.对齐填充区域(Padding)

  • JVM 的实现 HostSpot 规定对象的起始地址必须是 8 字节的整数倍。现在 64 位的 OS 往外读取数据的时候一次性读取64bit 整数倍的数据,也就是8 个字节,所以HotSpot 为了高效读取对象,就做了"对齐",如果一个对象实际占的内存大小不是 8byte 的整数倍时,就"补位"到 8byte 的整数倍。所以对齐填充区域的大小不是固定的。
2.JDK1.6中JVM对Synchronized的优化

锁消除和锁粗化,适应性自旋是虚拟机对低效的锁操作而进行的一个优化。

2.1.锁消除(Lock Elimination)

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

  • 简单来说,在编译期间,JVM会清除一些使用了同步,但同步块中没有涉及共享数据的锁,从而减少多余的同步。

如:使用StringBuffer的append方法,因为append方法需要判断对象是否被占用,而如果代码不存在锁的竞争,那么这部分的性能消耗是无意义的。于是虚拟机在即时编译的时候就会将上面代码进行优化,也就是锁消除。

那么虚拟机如何判断不存在同步情况呢?通过逃逸分析。可见下面伪代码

public static String createStringBuffer(String str1, String str2) {
        StringBuffer sb= new StringBuffer();
        sb.append(str1);// append方法是同步操作
        sb.append(str2);
        return sBuf.toString();// toString方法是同步操作
}
  • 可以看到sb对象使用的范围仅仅只在方法栈createStringBuffer中,因为return回去的对象是一个新的String对象。
  • 也就是说sb对象是不会逃逸出去从而被其他线程访问到,那就可以把它们当作栈上的数据对待,认为它们是线程私有,同步锁无需进行。
2.2.锁粗化(Lock Coarsening)

若有一系列操作,反复地对同一把锁进行加锁和解锁操作,编译器会扩大这部分代码的同步块的边界,从而只使用一次上锁和解锁操作。。

public static StringBuffer createStringBuffer(String str1, String str2) {
    StringBuffer sBuf = new StringBuffer();
    sBuf.append(str1);// append方法是同步操作
    sBuf.append(str2);// append方法是同步操作
    sBuf.append("abc");// append方法是同步操作
    return sBuf;
}
  • 当频繁的对sBuf进行加锁、解锁,会造成性能上的损失。如果虚拟机探测到有一系列连续操作都是对同一对象加锁,将会把加锁同步的范围扩展到整个操作的最外部,也就是在第一个和最后一个append操作之后

  • 用下面伪代码对锁粗化进行说明

for(int i=0;i<100000;i++){  
    synchronized(this){
        do();  
    }
}  

//在锁粗化之后运行逻辑如下列代码
synchronized(this){
    for(int i=0;i<100000;i++){
        do();
    }    
}  
2.3.适应性自旋锁(Adaptive Spinning)

背景:在许多场景中,同步资源的锁定时间很短,为了这一小段时间去阻塞或唤醒一个线程的时间可能比用户代码执行的时间还要长。为了让当前线程“稍等一下”,我们可以让线程进行自旋,如果在自旋过程中占用同步资源的线程已经释放了锁,那么当前线程就可以不进入阻塞而直接获取同步资源,从而避免切换线程的开销。


自旋锁(spinlock):即当一个线程在获取锁的时候,如果锁已经被其它线程获取,不是立即阻塞线程。那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。尝试获取锁的线程不会立即阻塞(放弃CPU时间片),采用循环的方式尝试获取锁!

  • 优点:不会使线程进入阻塞状态(放弃CPU时间片),通过占用CPU时间来避免线程切换带来的开销,避免了线程在尝试获得锁失败后,在 “挂起-再次尝试” 之间,不断上下文切换造成的资源浪费。
  • 缺点: 自旋等待虽然避免了线程切换的开销,但如果自旋锁获取锁的时间太长,会造成后面的线程CPU资源耗尽,因此自旋锁只适用于锁的占用时间较短的场景(自旋锁是不公平的)。
    • 如果持有锁的线程不能很快释放锁,线程CPU的占用时间(自旋过程)会过长,反而使得效率变低,性能下降。因此虚拟机限制了自旋次数(默认是10次,JDK1.6中通过-XX:+UseSpinning开启,可以使用-XX:PreBlockSpin来更改,JDK1.7后,去掉此参数,由JVM控制),如果自旋超过了限定次数,虚拟机会将线程挂起,让出cpu资源。


什么是自适应自旋锁:即:自旋的次数不再固定,由前一次在 同一个锁上的自旋时间 及 锁的拥有者的状态来决定。来计算出一个较为合理的本次自旋等待时间。

如果线程1自旋等待刚刚成功获得过锁,并且占有锁的线程2正在运行中,那么JVM就会认为线程1这次自旋也很有可能再次成功,进而允许线程1进行更多次的自旋等待。反过来说,如果某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费CPU资源。

2.4.简述偏向锁
  • 本质:在无竞争情况下把整个同步都消除掉,甚至连CAS操作都不做了,只需判断 Mark Word 中的一些值是否正确就行。进一步提升程序性能。

  • 与轻量级锁的区别:轻量级锁是在无竞争的情况下使用CAS操作来代替互斥同步((阻塞)的使用,从而实现同步;而偏向锁是在无竞争的情况下完全取消同步。

  • 与轻量级锁的相同点:它们都是乐观锁,都认为同步期间不会有其他线程竞争锁。

  • 原理:当线程请求到锁对象后,将锁对象的状态标志位改为01,即进入 “偏向锁状态”。然后使用CAS操作将线程ID记录在锁对象的Mark Word中。该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查MarkWord 的锁标记位为偏向锁 以及 当前线程 Id 等于 Mark Word 的 ThreadId 即可直接接进入同步块,连CAS操作都不需要。但是,一旦有第2个线程需要竞争锁,那么偏向模式立即结束,进入 “轻量级锁” 的状态。

  • 优点:偏向锁可以提高有同步但没有竞争的程序性能。但是如果锁对象时常被多个线程竞争,那偏向锁就是多余的。

    • 偏向锁JDK1.6之后默认开启。参数开启方式:
      • -XX:+UseBiasedLocking启动默认五秒之后生效
      • 立即生效:-XX:BiasedLockingStartupDelay=0
    • 在 JDK1.8 中,其实默认是轻量级锁,但如果设定了-XX:BiasedLockingStartupDelay = 0,那在对一个 Object 做 syncronized 的时候,会立即上一把偏向锁。
    • tips:偏向锁的“偏”,就是偏心的“偏”、偏袒的“偏”。它的意思是这个锁会偏向于第一个获得它的线程,如果在当前线程的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步
2.5.简述轻量级锁
  • 背景:『轻量级锁』是相对于『重量级锁』而言的,即使用操作系统互斥量来实现的传统锁

  • 本质:在无竞争的情况下使用CAS操作去取代同步使用的互斥量。

  • 轻量级锁与重量级锁的区别:

    • 重量级锁是一种悲观锁,它认为总是有多个线程要竞争锁,所以它每次处理共享数据时,不管当前系统中是否真的有线程在竞争锁,它都会使用互斥同步(阻塞)来保证线程的安全;
    • 而轻量级锁是一种乐观锁,它认为锁存在竞争的概率比较小,所以它不使用互斥同步,而是使用CAS操作来获得锁,这样能减少传统的重量级锁使用操作系统『互斥量』带来的性能开销。
  • 实现原理:

    • 对象头称为『Mark Word』,对象处于不同的状态下,Mark Word中存储的信息也所有不同。
    • Mark Word中有个标志位用来表示当前对象所处的状态。
    • 当线程请求锁时,若该锁对象的Mark Word中标志位为01(无锁状态),则在该线程的栈帧中创建一块名为『锁记录Lock Record』的空间,然后将锁对象的Mark Word拷贝至该空间;最后通过CAS操作将锁对象的Mark Word更新为指向Lock Record的指针
      • 若CAS更新指针成功,则轻量级锁的上锁过程成功;
      • 若CAS更新指针失败,再判断当前线程是否已经持有了该轻量级锁;若已经持有,直接进入同步块;若尚未持有,则表示该锁已经被其他线程占用,此时轻量级锁就要膨胀成 “重量级锁”。
  • 轻量级锁比重量级锁性能更高的前提:在轻量级锁被占用的整个同步周期内,不存在其他线程的竞争。若在该过程中一旦有其他线程竞争,那么就会膨胀成重量级锁,从而除了使用 互斥量 以外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。

    • 如果执行同步块的时间比较短,那么多个线程之间执行使用轻量级锁交替执行。
    • 如果执行同步块的时间比较长,那么多个线程之间刚开始使用轻量级锁,后面会膨胀为重量级锁。(因为执行同步块的时间长,线程 CAS 自旋获得轻量级锁失败后就会锁膨胀)
2.6.简述重量级锁

重量级锁是一种悲观锁,它认为总是有多个线程要竞争锁,所以它每次处理共享数据时,不管当前系统中是否真的有线程在竞争锁,它都会使用互斥同步(阻塞)来保证线程的安全;

  • 会发生上下文切换,CPU 状态从用户态转换为内核态执行操作系统提供的互斥锁,所以系统开销比较大,响应时间也比较缓慢。
3.锁升级 3.1.什么是锁升级

锁升级的过程其实就是对象头中的 Mark Word 数据结构改变的过程。是不可逆转的。

1.默认是无锁状态

2.偏向锁的判断

  • 在 JDK1.8 中,其实默认是轻量级锁,但如果设定了-XX:BiasedLockingStartupDelay = 0,那在无竞争的时候对一个 Object 做 syncronized 的时候,会立即上一把偏向锁。当处于偏向锁状态时, MarkWork 会记录当前线程 ID 。

3.升级到轻量级锁的判断

  • 一旦有第2个线程参与到偏向锁竞争时,会先判断 MarkWork中保存的 线程 ID 是否与这个线程 ID 相等,如果不相等,会立即撤销偏向锁,升级为轻量级锁。每个线程在自己的线程栈中生成一个LockRecord ( LR ),然后每个线程通过CAS (自旋)的操作将锁对象头中的 MarkWork设置为指向自己的LR的指针,哪个线程设置成功,就意味着获得锁。

4.升级到重量级锁的判断

  • 如果锁竞争加剧(如线程自旋次数或者自旋的线程数超过某阈值, JDK1.6 之后,由 JVM 自己控制该规则),就会升级为重量级锁。此时就会向操作系统申请资源,线程挂起,进入到操作系统内核态的等待队列中,等待操作系统调度,然后映射回用户态
    • 在重量级锁中,由于需要做内核态到用户态的转换,而这个过程中需要消耗较多时间,有可能比用户执行代码的时间还要长。也就是"重"的原因之一。

      重量级锁通过是对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的互斥量实现实现.

3.2.锁升级的四种锁状态的思路及特点

无锁、偏向锁 、 轻量级锁、 重量级锁都是指synchronized在某种场景下的状态,整体的锁状态升级流程如下:


Mark Word在不同锁状态下的结构


无锁 VS 偏向锁 VS 轻量级锁 VS 重量级

1.无锁状态

无锁没有对共享资源进行加锁,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

  • 无锁的特点:线程会不断自旋的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。
  • CAS应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。
2.偏向锁状态

偏向锁是指一段同步代码一直被同一个线程所访问,那么该线程会自动获取锁,减少同一线程获取锁的代价,省去了大量有关锁申请的操作。

  • 在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了 偏向锁。其目的就是在只有一个线程执行同步代码块时能够提高性能。

  • 当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。
  • 引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。
    • CAS 原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟(因为 CAS 的底层是利用LOCK 指令 + cmpxchg 汇编指令来保证原子性的
    • 当该线程再次请求锁时,无需再做任何同步操作,只需要检查MarkWord 的锁标记位为偏向锁 以及 当前线程 Id 等于 Mark Word 的 ThreadId 即可
  • 偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
3.轻量级锁状态

是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

  • 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。

  • 拷贝成功后,JVM将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。

    • 如果这个更新指针操作成功,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。
    • 如果更新操作失败,JVM首先会检查对象的Mark Word是否指向当前线程的栈帧,如果指向就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
  • 若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。

4.重量级锁状态

升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。

4.加锁和解锁的过程 4.1.加锁的过程

主要分为 3 步:

  1. 在线程进入同步块的时候,如果同步对象状态为无锁状态(锁标志为 01),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录( Lock Record)的空间,用来存储锁对象目前的 Mark Word 的拷贝。拷贝成功后,虚拟机将使用 CAS操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将Lock Record里的 owner 指针 指向锁对象的Mark Word。如果更新成功,则执行 2,否则执行 3。

  1. 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且锁对象的Mark Word中的锁标志位设置为"00",即表示此对象处于轻量级锁定状态,这时候虚拟机线程栈与堆中锁对象的对象头的状态如图所示。


3. 如果这个更新操作失败了,虚拟机首先会检查锁对象的Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重要量级锁,锁标志的状态值变为 "10",Mark Word 中存储的就是指向重量级锁的指针,后面等待锁的线程也要进入阻塞状态。而当前线程便尝试使用自旋来获取锁。自旋失败后膨胀为重量级锁,被阻塞。

4.2.解锁的过程
  • 因为虚拟机线程栈帧中的Displaced Mark Word(锁记录LR)是最初的无锁状态时的数据结构,所以用它来替换对象头中的 Mark Word 就可以释放锁。 如果锁已经膨胀为重量级,此时是不可被替换的,所以替换失败,唤醒被挂起的线程。
5.锁的优缺点
优点缺点使用场景
偏向锁加锁和解锁不需要CAS操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差距如果线程间存在锁竞争,会带来额外的锁撤销的消耗只有一个线程访问同步块或者同步方法的场景
轻量级锁使用CAS操作自旋获取锁,竞争线程不会阻塞,提高了响应速度若线程长时间抢不到锁,自旋会消耗CPU性能线程交替执行同步块或者同步方法的场景
重量级锁线程竞争不使用CAS自旋,不会消耗CPU直接调用互斥量阻塞线程,响应时间缓慢,在多线程下,频繁的加解锁会带来巨大的性能消耗追求吞吐量,同步块或者同步方法执行时间较长的场景
5.总结

综上所述:

  • 偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。
  • 轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。
  • 重量级锁是将除了拥有锁的线程以外的线程都阻塞。

Java中常见使用 synchroinzed 的地方有:

  • ConcurrentHashMap (jdk 1.8)
  • HashTable
  • StringBuffer

网络好文

  • 不可不说的Java“锁”事(CAS)
  • 队列同步器(AQS)
  • synchronized&volatile&synchronized原理
转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/584287.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号