Java并发——synchronized关键字解读



一、synchronized

1、synchronized的作用

  1. synchronized 表示同步的,指同一时刻,一个同步代码块(被synchronized修饰的方法或者代码块)只能被一个线程所获取并执行
  2. 当代码块被 synchronized 修饰时,表示此内部的代码属于临界区,不可被多个线程同时访问,必须互斥访问该代码块。其执行原理则 synchronized 定义的锁机制,当线程进入该代码块前,需要获取到该对象的锁,才可顺利进入访问资源。

2、并发编程的三大特性

在了解并发编程的知识过程中,不免了解到并发的三大特性:原子性、可见性、有序性。而 synchronized 关键字内部对三特性都定义了相关实现,如下对三特性的解析

  • 原子性一个操作或者多个操作要么全部执行完成,要么全部不执行。synchronized 关键字可以确保只有一个线程可以拿到对象锁。
  • 可见性当一个线程对共享变量进行修改后,其他线程能看到。 synchronized 关键字通过内存屏障指令保证了可见性,比如线程A获取对象锁后,访问同步方法区,并修改了数据,则内存屏障指令会将数据(如变量)强制刷新到主内存中,而当线程B再次获取到这个对象锁时,访问本地缓存中数据时,会让缓存数据失效,要求线程从主内存中读取最新数据,这样就保证了可见性。
    在这里插入图片描述
- 读屏障插入在读指令前面,清空缓存中的数据,使其失效,并从主内存中读取数据
- 写屏障插入在写指令后面,要求写入缓存的最新数据立刻刷新到主内存

当然内存屏障还有防止指令重排序问题,还有四种指令方式,需要了解的自行了解,这只是为了理解,就不拓开讲了

  • 有序性:顾名思义,即程序的执行顺序会按照代码的先后顺序执行的。有时候,编译优化器和处理器可能针对代码顺序进行优化,达到效率最好的目的,但是并不是所以的重排序是好的,在并发操作中,可能会导致结果的不一致问题。而 synchronized 关键字保证了一个线程执行该同步方法区,相当于单线程,保证了结果的有序性,不会被其他线程干扰。

3、synchronized的使用方式

有三种应用方式:修饰普通方法、修饰静态方法、修饰代码块

修饰普通方法
public synchronized void 方法名() {
   代码主体
}

修饰静态方法
public synchronized static  void 方法名() {
  代码主体
}

修饰代码块
public void 方法名() {
    synchronized (this) {
      代码主体
    }
}

  • 修饰普通方法
    先来看一段没有被 sychronized 修饰的代码
public class Main implements Runnable {
    共享资源
    static int i = 0;
   没有 synchronized 修饰的普通方法
    public void add() {  
        i ++;
    }
    @Override
    public void run() {
        for(int j=0;j<1000;j++){
            add();
        }
    }
    public static void main(String args[]) throws Exception {
         建立了一个对象
        Main mian = new Main(); 
        Thread t1 = new Thread(mian);
        Thread t2 = new Thread(mian);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("最终结果 i :" + i);
    }
}

此时有两个线程,所以必然有两个线程在争抢资源,而 i++是复合操作,不是原子性的,需要取值、相加、赋值三个步骤,导致结果变小

最终结果 i :1914

那怎么办呢?那就让 i++ 变为原子性似的操作,而加上关键字 synchronized 即可实现该功能


public class Main implements Runnable {
    共享资源
    static int i = 0;
    // synchronized 同步方法
    public synchronized void add() {
        i ++;
    }
    @Override
    public void run() {
        for(int j=0;j<1000;j++){
            add();
        }
    }
    public static void main(String args[]) throws Exception {
        建立了一个对象
        Main mian = new Main(); 
        Thread t1 = new Thread(mian);
        Thread t2 = new Thread(mian);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("最终结果 i :" + i);  
    }
}

这是一个经典的案例,synchronized 修饰普通方法,使其结果不会被其他线程干扰

最终结果 i :2000

但是,我们也不难发现这是针对同一个对象下的发生情景,如果是实例化了多个对象呢?让我们继续往下看

  • 修饰静态方法
    直接看代码
public class Main implements Runnable {
    共享资源
    static int i = 0;
    // synchronized 同步方法
    public synchronized  void add() {
        i ++;
    }
    @Override
    public void run() {
        for(int j=0;j<1000;j++){
            add();
        }
    }
    public static void main(String args[]) throws Exception {
        实例化了两个对象
        Thread t1 = new Thread(new Main());
        Thread t2 = new Thread(new Main());
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("最终结果 i :" + i);
    }
}

发现add() 方法已经被 synchronized 修饰,但运行后,发现并不是每次结果都如我们所愿,有时候结果并不是 2000 ,而是小于2000

最终结果 i :1173

这是为什么呢?
首先我们需要明确的一点就是 synchronized 中所说争抢的锁,是针对对象的,即多线程并发状态下,所说的锁的细粒度大小就是对象
而上面代码中,我们发现实例化了两个对象,所以线程t1、t2使用的是不同的对象锁,自然不能达到约束的效果。可以理解为类是一个模板,用它创造了不同对象,那自然有不同的锁了
那怎么可以让结果一致,符合我们的期望呢?
那就把锁加入模板呗,而我们知道静态方法是属于类的,把 synchronized关键字修饰在静态方法上,自然可以达到同样的效果,让线程竞争是同一把锁。

public class Main implements Runnable {
    共享资源
    static int i = 0;
    // synchronized 同步方法
    public synchronized static  void add() {
        i ++;
    }
    @Override
    public void run() {
        for(int j=0;j<1000;j++){
            add();
        }
    }
    public static void main(String args[]) throws Exception {
        实例化了两个对象
        Thread t1 = new Thread(new Main());
        Thread t2 = new Thread(new Main());
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("最终结果 i :" + i);
    }
}

输出结果如下,运行多次,依然达到我们的预期效果

最终结果 i :2000
  • 修饰代码块
    有时候,我们定义了一个功能模块,这个功能主体代码非常复杂,体量大,而其中只要一小部分代码需要同步条件。这样的情况下,我们直接用关键字 synchronized 修饰会导致整体代码性能降低,所以我们只需要对需要同步的代码进行修饰即可。所以定义了synchronized 修饰的代码块
public class Main implements Runnable {
    共享资源
    static int i = 0;

    @Override
    public void run() {
        其他复杂代码.......
        // synchronized修饰这一小块代码块
        // this表示当前对象实例
        synchronized (this){
            for (int j = 0; j < 1000; j++) {
                i++;
            }
        }

    }

    public static void main(String args[]) throws Exception {
        Main main = new Main();
        Thread t1 = new Thread(main);
        Thread t2 = new Thread(main);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("最终结果 i :" + i);
    }
}

运行多次,结果一致

最终结果 i :2000

二、synchronized 的四种锁

1、synchronized 底层原理

在学习四种锁时,先了解一下 synchronized 实现锁的原理
对于Java对象,它在内存中被划分为三部分:对象头、实例数据和填充对齐,我们这里主要针对对象头了解即可。
对象头中存储了 锁状态标志位、、存储到对象类型数据的指针、数组长度等,在32/64位虚拟机中,Java对象头的组成如下:

内容说明长度
Mark Word存储对象的哈希码、分代年龄、锁标记位32/64bit
Class Metadata Address存储到对象类型数据的指针32/64bit
Array length数组的长度32/64bit

对于对象头,我们再把焦点聚集到 Mark Word 中,我们来看一下Mark Word的大致内容

锁状态存储偏向位锁标志位
无锁状态存储对象的哈希码、分代年龄等001
偏向锁当前线程指针ID101
轻量级锁指向栈帧的Lock Record的指针00
重量级锁指向堆互斥量的指针10

结合上面的图文,总结而言呢,就是synchronized是基于对象实现的,对象在内存中布局分为对象头、实例数据和填充对齐三个部分,在运行期间,JVM将线程与对象的对象头关联起来,对象头中的Mark Word此部分,随着标志位的改变,里面存储的信息也会改变。比如是当前线程ID,则表示线程拿到了对象锁,为偏向锁(这部分还有挺多知识的,比如详细的指令实现,我没有深入了解,只是知道这个大概和流程,大家觉得不够理解的,再去看看别的博主文章,我这主要是记录自己学习,加深印象,见谅)

2、四种状态锁

最开始对象是没有锁的,即无锁状态,当仅且一个线程A访问了同步代码区,此时没有其他竞争线程,则会升级为偏向锁;
而有多个线程时,JVM 会把锁升级为轻量级锁来避免用户态和内核态切换的性能消耗;如果发生线程竞争,线程则会自适应自旋,让线程自旋等待一段时间,不会立马进入堵塞状态,但是这样长时间自旋会消耗CPU,如果持续获取不到对象会升级为重量级锁,线程堵塞等待。

锁升级大致过程
在这里插入图片描述


  • 偏向锁
    偏向锁是由无锁状态升级而成, 当处于多线程环境时,为了性能优化而设计的,目的是如果线程得到锁之后,可以消除线程重入的开销,提高性能;当多线程竞争时,争抢的线程会执行CAS操作,希望争取到锁,如果没有争取到,则升级为轻量锁
    偏向锁执行步骤
    看下图可知执行偏向锁为大致两个方法:有锁、无锁
    无锁:判断是否可偏向,如果可偏向,则执行CAS操作,替换对象头的MarkWord的线程ID,如果失败则说明被其他线程抢占了锁,现在把锁升级为轻量级锁
    有锁:判断是否是偏向锁,如果是则判断是否是当前自己线程占据了锁,是,则直接执行同步代码,如果不是则执行CAS操作,转为第一步的流程;如果有锁,但不是偏向锁,则可能是轻量级锁,也可能是重量级锁,根据锁的状态执行对应的操作。

在这里插入图片描述
CAS是什么?

CAS全名 Compare And Swap ,如字面意思比较并交换,CAS属于一种乐观锁的操作策略。
CAS操作包含了三个操作数:V、A、N,其中V表示内存值,E表示期望值,N表示新值。当内存值和期望值时,才会把内存值更新为新值,如果不等,则CAS操作失败,不做更新。


  • 轻量级锁
    轻量级锁由偏向锁升级而来,当处于多线程环境时,如果发生竞争,那轻量级锁不会立马转为重量级锁,不会让线程立马堵塞等待。而是让线程进入自旋等待中,等待时间和自旋次数由上一次线程争抢状态决定,上一次容易争取到,就可能会自旋次数多点,反之则减少自旋次数。如果持续没有拿到锁,则升级为重量级锁。
    当偏向锁升级为轻量级锁的过程中,需要等待全局安全点,让所有的线程挂起,这样JVM才能安全标记对象,当符合全局安全点时,通过Markword中的线程ID获取当前占据对象的线程,在该线程的栈帧中升级为轻量级锁并保存锁记录(Lock Record),最后把对象的MarkWord更新为指向该锁记录的地址
    简而言之:在线程中保存锁记录(Lock Record),并把对象中的MarkWord更新,把线程ID更新为锁记录地址(Lock Record),即地址指向了栈帧中的锁记录地址,并把锁标志位更新为00,此时升级成功。

如果线程B此时来竞争,会先判断对象的锁状态,然后发现有锁,是轻量级锁,如果WarkWord指向本身线程栈帧的锁记录,则直接执行(这是重入),如果不是,则执行CAS操作,抢占锁,成功即执行同步代码,失败则线程自旋等待,一直不成功则升级为重量级锁。

自旋理解

你可以理解为就是等一小会,比如高铁上厕所,你到了厕所门口,发现有人,你没有立马回到座位上去,而是在门口等待了一小会,期望对方很快就会好。
这样做有什么好处?
避免用户线程和内核的频繁切换带来的消耗,提高性能。


  • 重量级锁
    重量级锁基于底层的互斥锁实现,竞争时,没拿到锁的线程需要阻塞等待锁,保证只有一个线程此时占用资源并修改Mark Word使指针指向ObjectMonitor,如果拥有锁的线程执行完,则会释放锁后唤醒其他线程继续竞争锁。此时操作系统会切换用态和内核态,所以重量级锁效率很低。
重量级加加锁过程
1. 现在对象处于无锁状态,直接获取锁
2. 现在锁被当前线程占用,直接执行,属于重入
3. 当前锁指向当前线程帧记录,说明当前线程是之前轻量级锁的持有者,直接执行,属于重入
4. 如果发现有锁,且不符合前面三条。则尝试获取锁,不成功,则自旋等待,尝试获取锁
5. 自旋加锁失败,则加入堵塞队列头部,并再次尝试加锁
6. 如果还是加锁失败,则线程挂起,等待唤醒

这里重量级锁升级和加锁过程我了解不多,大家看看这个博主的,就是需要点耐心
重量级锁原理深入剖析


  • synchronized 的可重入锁
    可重入的条件即是当前获取的对象,是当前本身线程占据,也可以叫递归锁
    表示当线程占用该锁后,需要再次调用该方法,则不要解锁加锁等操作,可以直接执行,并记录一个数加一,表示重入次数,释放锁则需要该重入次数为零。
    比如一个方法,内部函数调用该方法,则直接执行,并重入次数加一,执行完,则减一,继续执行,直到减为0,此时,线程释放锁。

总结

  1. 同步方法,只能被获取对象锁的线程执行,其他非同步方法可被其他线程执行
  2. 锁的细粒度大小是对象,synchronized 的锁基于对象的
  3. synchronized 可以修饰普通方法,静态方法、代码块
  4. synchronized 的四种锁,依次升级表示为无锁、偏向锁、轻量级锁、重量级锁
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值