带你了解synchronized关键字

导航

内容导图

关于线程安全的那些事儿

锁原来有这么多分类

synchronized的作用域

锁的优化

锁的原理


我们在学习Java多线程时一定听说过synchronized关键子。而本文就会带你了解synchronized关键字中暗藏的玄机。

整个学习内容如下图,咱们接着往下看。

内容导图


关于线程安全的那些事儿

要完全理解synchronized,咱们就要先知道synchronized的出现是为了什么——解决线程安全问题。

首先出现线程安全问题的原因主要由两方面构成,可见性与原子性。在这里不详细展开,只是大致的解释一下相关问题。

可见性:在JMM(Java内存模型)中所有变量都存储在主内存中,而每个线程都有自己独立的工作内存,里面保存该线程的使用到的变量副本(该副本就是主内存中该变量的一份拷贝)。线程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接在主内存中读。不同线程之间无法直接访问其他线程工作内存中的变量,这就导致线程间变量值的传递需要通过主内存来完成。而这一传递过程是需要耗时的

那么问题来了。当一个线程对共享变量的修改时,而其他线程不能够及时的看到,这时候就产生了可见性问题。

原子性:是指一个操作是不可中断的. 即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其它线程干扰。这里可以理解为是一个CAS操作,至于什么是CAS操作,这里不做具体讲解。我在之后的博客中会单独说明。

那么问题来了,在程序执行过程中, 为了性能考虑, 编译器和CPU可能会对指令重新排序。那么在源码(.java)中看上去像是原子性操作的代码,经过编译器编译后的字节码(.class)指令可能是由多个步骤组成。既然指令可以被分解为很多步骤, 那么多条指令就不一定依次序执行(会有其他线程抢先执行)。这时候就产生了原子性问题。


锁原来有这么多分类

在Java锁中锁可以分为如下几类,请注意,一下的分类不是同一纬度的,而是从不同纬度进行的分类,也就是说,一个锁它可以是悲观锁,同时也可以是可重入锁。其实synchronized就是一个可重入锁,独享锁,悲观锁

  • 自旋锁:是指当一个线程在获取锁当时候,如果锁已经被其他线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
  • 乐观锁:假定没有冲突,在修改数据时如果发现数据和之前获取的不一致,则读最新数据,修改后重试修改
  • 悲观锁:假定会发生冲突,同步所有对数据的相关操作,从读数据就开始上锁。
  • 独享锁(写):给资源加上写锁,线程可以修改资源,其他线程不能再加锁;(单写)
  • 共享锁(读):给资源加上读锁后只能读不能改,其他线程也只能读锁,不能加写锁;(多读)
  • 可重入、不可重入锁:线程拿到一把锁之后,可以自由进入同一把锁锁同步的其他代码。
  • 公平锁、非公平锁:争抢锁的顺序,如果是按先来后到,则为公平。

这里咱们看下可重入锁。下面这段代码使用递归执行,控制台打印结果不会阻塞,也就是说,虽然已经上了锁,但是再进入这段被锁的代码后,只要是同一把锁,还是会执行下去。也就是说会不停的每隔2s打印一次“This i is [i]”

package com.xavier.common;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * @author : xavier
 * @version : 1.0
 */
public class ReentrantTest {

    private static int i = 0;

    private final static Lock LOCK = new ReentrantLock();

    public static void main(String[] args) throws InterruptedException {
        test();
    }

    private static void test() throws InterruptedException {
        LOCK.lock();
        i++;
        System.out.println("This i is " + i);
        Thread.sleep(2000L);
        test();
        LOCK.unlock();
    }
}

synchronized的作用域

请看下面这段代码,在使用synchronized关键字时 add() 与 add1() 锁定的是对象,而 addStatic() 与 add2() 锁定的是类对象。

add() 与 addStatic() 是隐式指定锁对象的,add1() 与 add2() 则是显示的指定锁对象的。

package com.xavier.common;
/**
 * @author : xavier
 * @version : 1.0
 */
public class Sync {

    public static void main(String[] args) {
        Sync sync1 = new Sync();
        Sync sync2 = new Sync();
        sync1.add();
        sync2.add();
    }

    private static int i;

    /**
     * 对象锁
     */
    public synchronized void add() {
        i++;
    }

    /**
     * 类锁
     */
    public synchronized static void addStatic() {
        i++;
    }

    /**
     * 对象锁,锁住的是当前的对象,当创建多个对象的时候,会有多个锁,起不到互斥的作用,锁会失效。
     */
    public void add1() {
        synchronized (this) {

        }
    }

    /**
     * 类锁,类对象在方法区。不会存在对象锁的问题。
     */
    public static void add2() {
        synchronized (Sync.class) {

        }
    }
}

锁的优化

锁消除:在JVM中如果发现当前锁始终只有一个线程在调用,不存在竞争关系,那么它就会将synchronized优化,即消除锁。注意:Lock接口中的Reenterlock并不会优化。

锁粗化:请看下面这段代码,不要吐槽写的烂(我就是故意这样写的:))

package com.xavier.common;
/**
 * @author : xavier
 * @version : 1.0
 */
public class Test {
    
    int i = 0;
    
    public void test() {
        
        synchronized (this) {
            i++;
        }
        
        synchronized (this) {
            i--;
        }

        System.out.println("This is a test");
        
        synchronized (this) {
            System.out.println("This is a test");
        }
        
        synchronized (this) {
            i++;
        }
    }
}

上面这段代码会被JIT即时编译优化为下面。注意:如果是耗时操作,就不会这样优化了。长时间的持有锁,也是消耗性能的。

package com.xavier.common;
/**
 * @author : xavier
 * @version : 1.0
 */
public class Test {

    int i = 0;

    public void test() {

        synchronized (this) {
            i++;
            i--;
            System.out.println("This is a test");
            System.out.println("This is a test");
            i++;
        }
    }
}

这就是锁的粗化,是不是瞬间就懂了呢。

偏向锁:偏向锁主要目的是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。应为哪怕是轻量级锁也需要CAS操作和自旋操作。在JDK6以后,默认已经开启了偏向锁这个优化,通过JVM参数-XX:-UseBiasedLocking来禁用偏向锁。若偏向锁开启,只有一个线程抢锁,可获取到偏向锁,只有一个线程的时候就会使用到偏向锁。

偏向锁是不会释放的,也就是说 thread id不会置为零

轻量级锁:轻量级锁的主要目的是在多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁


锁的原理

在了解偏向锁与轻量级锁之前,咱们先看一下下面这张图。

图中左边是Java对象在堆内存中的简要模型包括对象头,实例数据引用和对齐填充,其中,对象头Object Head中包含了图中右边的相关信息,而咱们要知道的就是粉红色标记的Mark Word

让我们看看Mark Word的owner里面包含了那些信息吧。如下图,这是详细的对象锁状态在Mark Word的owner的信息。

BitfieldsTagstate 
HashcodeAge001Unlocked未锁定
Thread IDAge101Biased/ biased偏向锁
Lock record address00

Light-weight locked

轻量级锁
Monitor address10Heavy-weight locked重量级锁
Forwarding address, etc.11Marked for GC 

线程会在虚拟机栈帧中开辟一块内存空间,存放拷贝的Lock Record 其中包括 Hashcode Age 0,当要抢锁的时候,会进行CAS操作将Lock record address 修改, 其中owner指向当前的对象头的mark word。当线程抢锁失败后就会进入自旋,达到一定次数就会进行锁升级,将轻量级锁升级为重量级锁。注意:如果线程复制不到Lock Record则会直接升级成重量级锁。

重量级锁原理如下

如图:

synchronized的对象锁,其指针指向的是一个monitor对象(由C++实现)的起始地址。每个对象实例都会有一个 monitor。其中monitor可以与对象一起创建、销毁;亦或者当线程试图获取对象锁时自动生成。

在对象监视器 Monitor 中,当线程抢占锁成功后,就会将 Lock record address 中的 00 修改为 10 并将 Lock record address修改为Monitor address。

在Monitor中 保存有owner信息(t1,t2,t3代表线程),可以理解为当前线程的引用信息。

当其他线程抢锁的自旋次数超过临界值的时候,Monitor就会将该线程放入锁池(entryList)中此时(先近先出),线程出现blocked状态

hotspot源码中可查看以上内容 ObjectMontior文件中

只用在抢到锁的时候(即owner指向的是当前线程)才能调用wait方法,当调用wait方法的时候,线程将锁释放掉(owner = null),线程进入到waitSet(等待池),线程状态出现 waiting。

这里需要注意,虽然entryList先进先出,但是也不能保证是公平的,因为其他线程可以随时抢锁,即在owner被释放掉的那一刻,在锁池中的线程需要和未进入锁池的线程同时抢锁。

而当线程唤醒的时候(从waiting状态变为runnable状态),会先从等待池出来抢占owner,如果抢占失败,则进入锁池,线程呈现blocked状态。

代码执行完就会执行montiorExit解锁

锁的升级过程

如下图展示

如果其他线程过来抢锁,锁未被占用的情况下会关闭偏向锁,然后将未锁定的已经关闭的偏向锁升级成轻量级锁,此时保存的是

Lock record address。

当升级成为轻量级的锁被释放,就会回到未锁定,关闭偏向锁的状态。

也就是说,一旦偏向锁 升级了,就不会退回到偏向锁的状态 了 

当其他线程自选争抢 轻量级锁到临界点时,就会升级成 重量级锁,这时候mark word中的Bitfields 将由原来的Lock record address 替换成 Monitor addres

需要注意的是,由于锁升级是不可逆的,所以当升级成重量级锁释放锁后,下一个线程获取到的锁任然会是重量级锁

结尾

好了,到这里基本上关于synchronized关键字的分析就告一段落了,之后我会继续补充和完善关于java锁的内容。希望大家能通过这篇文章有所收获!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值