面试之synchronized关键字

在这里插入图片描述
今天文章要探讨的问题是synchronized关键字。要了解synchronized关键字的使用及原理,我们首先需要知道为什么需要使用synchronized,这个关键字解决了什么问题? 文章主要分为以下几个方面:

  • 并发编程的三个特性
  • synchronized的使用
  • synchronized原理
  • synchronized锁升级
  • 面试题分享

一、并发编程中的需要满足的三个特性

1、原子性
1)原子性概念

一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行

2)原子性演示
  • 五个线程各执行1000次计算number的值:
public class Test02Atomicity {
    private static int number = 0;
    public static void main(String[] args) throws InterruptedException {
        Runnable increment = () -> {
        	for (int i = 0; i < 1000; i++) {
        		number++;
        	}
    	};
        ArrayList<Thread> ts = new ArrayList<>();
        //创建五个线程各执行1000次
        for (int i = 0; i < 5; i++) {
            Thread t = new Thread(increment);
            t.start();
            ts.add(t);
        }
        for (Thread t : ts) {
        	t.join();
        }
        System.out.println("number = " + number);
    }
}
  • 使用javap反汇编得到字节码指令:
    在这里插入图片描述

对于 number++ 而言(number 为静态变量),实际会产生如下的 JVM 字节码指令:

9: getstatic #12 // Field number:I
12: iconst_1
13: iadd
14: putstatic #12 // Field number:I

由此可见number++是由多条语句组成,以上多条指令在一个线程的情况下是不会出问题的,但是在多
线程情况下就可能会出现问题。比如一个线程在执行13: iadd时,另一个线程又执行9: getstatic。会导
致两次number++,实际上只加了1

2、可见性
1)可见性概念

当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。显然,对于单线程来说,可见性问题是不存在的

2)可见性演示
public class Test01Visibility {
    // 1.创建一个共享变量
    private static boolean flag = true;
    public static void main(String[] args) throws InterruptedException {
        // 2.创建一条线程不断读取共享变量
        new Thread(() -> {
            while (flag) {

            }
        }).start();

        Thread.sleep(2000);

        // 3.创建一条线程修改共享变量
        new Thread(() -> {
            flag = false;
            System.out.println("线程修改了变量的值为false");
        }).start();
    }
}
  • 一个线程根据boolean类型的标记flag, while循环,另一个线程改变这个flag变量的值,另
    一个线程并不会停止循环。就是因为对于flag变量来说并没有满足线程的可见性,导致了线程2修改flag之后,线程1并没有立即探测到线程2对于flag的修改。
3、有序性
1)有序性概念

有序性即程序执行的顺序按照代码的先后顺序执行。Java在编译时和运行时会对代码进行优化,会导致程序最终的执行顺序不一定就是我们编写代码时的顺序

2)有序性演示
// 线程 1
init();
inited = true;
 
// 线程 2
while(inited){
    work();
}

在单线程条件下,以上代码没有数据依赖,对于代码的执行顺序的改变并没有带来多大的影响,但是如果是处于多线程的环境下,如果对于线程1先执行inited=true,但是此时init()并没有执行,此时线程2就已经开始执行while循环了,这个时候就有可能因为init()方法的未执行而导致程序出现bug。

对于有序性的演示大家有兴趣可以使用jcstress等相关压测工具去进行测试,模拟多个线程访问的情况下代码是否有改变顺序执行,这里由于篇幅原因不展开讲。

二、synchronized的使用

1、定义

上文中提到了并发环境的我们需要满足的三个特性——原子性、可见性、有序性,synchronized作为java中的锁机制,解决的就是多线程访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行,在java中,每一个对象有且仅有一个同步锁。这也意味着,同步锁是依赖于对象而存在。

  • 当我们调用某对象的synchronized方法时,就获取了该对象的同步锁。例如,synchronized(obj)就获取了“obj这个对象”的同步锁。
  • **不同线程对同步锁的访问是互斥的。**也就是说,某时间点,对象的同步锁只能被一个线程获取到
2、synchronized实例
  • 同步代码块

synchronized 关键字可以用于方法中的某个区块中,表示只对这个区块的资源实行互斥访问

public synchronized void foo1() {
    System.out.println("synchronized methoed");
}
  • 同步锁

对象的同步锁只是一个概念,可以想象为在对象上标记了一个锁,这个锁可以是任意的对象,在任何时候,最多允许一个线程拥有该同步锁,谁拿到锁就进入代码块,其他的线程只能是阻塞状态

Object obj = new Object();
public void foo2() {
    synchronized (obj) {
        System.out.println("synchronized methoed");
    }
}
  • 全局锁

在类的方法上加上static,再使用synchronized,相当于锁定当前类,T.class;

如果没有static就是普通的方法,再使用synchronized,相当于锁定一个对象,new T();

public class T{
    private static int count=10;
    
    //静态方法的sync,相当于synchronized(T.class)
    public synchronized static void m() {//这里辞同于synchronized(T.class)
        count--;
        system.out.println(Thread.currentThread().getName() + "count-" + count);
    }
    
    public static void mm () {
        synchronized (T.c1ass) {
            count--;
        }
    }
}
3、synchronized基本准则

synchronized关键字的基本准则——

1)**可重入:**一个线程可以多次执行synchronized,重复获取同一把锁,演示代码如下:

public class Demo01 {
    public static void main(String[] args) {
        Runnable sellTicket = new Runnable() {
            @Override
            public void run() {
                synchronized (Demo01.class) {
                    System.out.println("我是run");
                    //线程调用test01方法
                    test01();
            	}
        	}
            //在test01方法中又重复获得Demo01对象锁
            public void test01() {
                synchronized (Demo01.class) {
                	System.out.println("我是test01");
                }
            }
        };
        new Thread(sellTicket).start();
        new Thread(sellTicket).start();
    }
}

2)**不可中断:**一个线程获得锁后,另一个线程想要获得锁,必须处于阻塞或等待状态,如果第一个线程不释放锁,第二个线程会一直阻塞或等待,不可被中断;代码演示如下:

public class Demo02_Uninterruptible {
    private static Object obj = new Object();
    public static void main(String[] args) throws InterruptedException {
        // 1.定义一个Runnable
        Runnable run = () -> {
            // 2.在Runnable定义同步代码块
            synchronized (obj) {
                String name = Thread.currentThread().getName();
                System.out.println(name + "进入同步代码块");
                // 保证不退出同步代码块
                try {
                    Thread.sleep(888888);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        };
        // 3.先开启一个线程来执行同步代码块
        Thread t1 = new Thread(run);
        t1.start();
        Thread.sleep(1000);
        // 4.后开启一个线程来执行同步代码块(阻塞状态)
        Thread t2 = new Thread(run);
        t2.start();
        // 5.停止第二个线程
        System.out.println("停止线程前");
        t2.interrupt();
        System.out.println("停止线程后");
        System.out.println(t1.getState());
        System.out.println(t2.getState());
    }
}

具体的情况如下:

  • 当一个线程访问“某对象”的“synchronized方法”或者“synchronized代码块”时,其他线程对**“该对象”的该“synchronized方法”或者“synchronized代码块”的访问**将被阻塞。
  • 当一个线程访问“某对象”的“synchronized方法”或者“synchronized代码块”时,其他线程仍然可以访问“该对象”的非同步代码块
  • 当一个线程访问“某对象”的“synchronized方法”或者“synchronized代码块”时,其他线程对**“该对象”的其他的“synchronized方法”或者“synchronized代码块”的访问**将被阻塞。
  • 一个同步方法可以调用另外一个同步方法,一个线程已经拥有某个对象的锁,再次申请的时候仍然会得到该对象的锁,也就是说synchronized获得的锁是可重入的。

三、synchronized原理

通过javap反汇编学习synchronized的原理,我们先写一个简单的synchronized代码来进行演示:

public class Demo01 {
    private static Object obj = new Object();

    public static void main(String[] args) {
        synchronized (obj) {
            System.out.println("1");
        }
    }

    public synchronized void test() {
        System.out.println("a");
    }
}

通过反汇编工具javap查看字节码指令:

javap -p -v -c .\Demo01.class 

字节码指令如下:
在这里插入图片描述

在上图字节码指令中我们可以看到:monitorenter作为synchronized关键字的开始,monitorexit作为结束:
在这里插入图片描述

1、monitorenter

首先我们来看一下JVM对于monitorenter的描述:

https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5.monitorenter

The objectref must be of type reference.

Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows:

  • If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor.
  • If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count.
  • If another thread already owns the monitor associated with objectref, the thread blocks until the monitor’s entry count is zero, then tries again to gain ownership.

每个对象都与一个监视器关联。 监视器只有在拥有所有者的情况下才被锁定。 执行* monitorenter 的线程尝试获得与 objectref *关联的监视器的所有权,如下所示:

-如果与* objectref 关联的监视器的条目计数为零,则线程进入监视器,并将其条目计数设置为1。 然后,该线程是监视器的所有者。
-如果线程已经拥有与
objectref 关联的监视器,则它将重新进入监视器,从而增加其条目计数。
-如果另一个线程已经拥有与
objectref *关联的监视器,则该线程将阻塞直到监视器的条目计数为零,然后再次尝试获取所有权。

也就是说:synchronized的锁对象会关联一个monitor,这个monitor不是我们主动创建的,是JVM的线程执行到这个同步代码块,发现锁对象没有monitor就会创建monitor,monitor内部有两个重要的成员变量

  • owner:拥有这把锁的线程
  • recursions会记录线程拥有锁的次数,当一个线程拥有monitor后其他线程只能等待
2、monitorexit

JVM规范中对于monitorexit的描述:

https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5.monitorexit

The objectref must be of type reference.
The thread that executes monitorexit must be the owner of themonitor associated with the instance referenced by objectref.
The thread decrements the entry count of the monitor associatedwith objectref. If as a result the value of the entry count iszero, the thread exits the monitor and is no longer itsowner. Other threads that are blocking to enter the monitor areallowed to attempt to do so.

  • 能执行monitorexit指令的线程一定是拥有当前对象的monitor的所有权的线程。

  • 执行monitorexit时会将monitor的进入数减1。当monitor的进入数减为0时,当前线程退出
    monitor,不再拥有monitor的所有权,此时其他被这个monitor阻塞的线程可以尝试去获取这个
    monitor的所有权

3、java对象布局

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下图所示:
在这里插入图片描述

当一个线程尝试访问synchronized修饰的代码块时,它首先要获得锁,那么这个锁到底存在哪里呢?是存在锁对象的对象头中的。

  • mark word:用于存储自身运行时数据,记录了对象和锁有关信息

  • klass word:用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例

1)当虚拟机位长为32位时,markword的字节分布如下所示:

Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等;此时synchronized会分为偏向锁、轻量级锁和重量级锁(后文会讲到),根据不同的锁标志位记录锁的状态,
在这里插入图片描述

2)当虚拟机位长为64位时,markword存储结构如下:

和32位虚拟机大同小异
在这里插入图片描述

四、synchronized锁升级

锁主要存在四种状态:无锁—》偏向锁—》轻量级锁—》重量级锁

随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率

1、偏向锁

这个锁会偏向于第一个获得它的线程,此时在java对象头和栈帧中会记录获得锁的线程ID。当之后此线程要再次获取锁的时候只需要比较线程ID和对象头中线程ID是否一致,

  • 如果一致那么就不需要CAS算法来获取锁

  • 如果不一致,说明有其他锁要竞争,此时查看对象头中的线程是否存活

    • 如果线程不存活,那么竞争的线程直接获得锁并设置为它的偏向锁
    • 如果线程存活并且当先线程还要使用锁,那么就升级为轻量级锁

但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。

偏向锁原理:

当线程第一次访问同步块并获取锁时,偏向锁处理流程如下:

1)虚拟机将会把对象头中的标志位设为“01”,即偏向模式。

3)同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中 ,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作,偏向锁的效率高。

偏向锁的好处:

偏向锁是在只有一个线程执行同步块时进一步提高性能,适用于一个线程反复获得同一锁的情况。偏向锁可以提高带有同步但无竞争的程序性能

2、轻量级锁

轻量级锁的目的:

轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。

轻量级锁在多线程竞争的情况下,使用自旋的操作。让没有竞争到锁的线程不挂起(因为线程竞争锁失败就会挂起线程,这个操作依赖与用户态和内核态的转换,耗时较多),让线程一直自旋尝试获得锁。自旋的次数一般默认为10如果超过线程自旋的次数还没获取到锁,此时锁会升级为重量级锁轻量级锁的加锁和释放锁都依赖于CAS算法

3、自旋锁和适应性自旋锁

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。

互斥同步对性能最大的影响就是阻塞的实现,因为挂起线程/恢复线程的操作都需要转入内核态中完成(用户态转换到内核态会耗费时间),可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋) , 这项技术就是所谓的自旋锁

自旋等待不能完全替代阻塞,因为它还是要占用处理器时间。如果锁被占用的时间短,那么效果当然就很好了!反之,相反!自旋等待的时间必须要有限度。如果自旋超过了限定次数任然没有获得锁,就应该挂起线程。自旋次数的默认值是10次,用户可以修改**–XX:PreBlockSpin****来更改**

在 JDK1.6 中引入了自适应的自旋锁。自适应的自旋锁带来的改进就是:自旋的时间不在固定了,而是和前一次同一个锁上的自旋时间以及锁的拥有者的状态来决定,

4、锁消除

如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行,锁消除可以节省毫无意义的请求锁的时间。虚拟机即时编译器(JIT)在运行时,就会将锁清除

public class Demo01 {
    public static void main(String[] args) {
        contactString("aa", "bb", "cc");
    }
    public static String contactString(String s1, String s2, String s3) {
        return new StringBuffer().append(s1).append(s2).append(s3).toString();
    }
}

StringBuffer的append ( ) 是一个同步方法,锁就是this也就是(new StringBuilder())。虚拟机发现它的动态作用域被限制在concatString( )方法内部。也就是说, new StringBuilder()对象的引用永远不会“逃逸”到concatString ( )方法之外,其他线程无法访问到它,因此,虽然这里有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。

5、锁粗化

JVM会探测到一连串细小的操作都使用同一个对象加锁,将同步代码块的范围放大,放到这串操作的外面,这样只需要加一次锁即可
在这里插入图片描述

面试题

1、synchroznied出现异常会释放锁吗?

会释放锁,在上图的代码反汇编码中我们可以看到在最后有一部分为Exception table,也就是在异常发生时指令的跳转,monitorexit插入在方法结束处和异常处,JVM保证每个monitorenter必须有对应的monitorexit
在这里插入图片描述

2、synchronized的使用

正文中有提到,主要是三个场景:同步代码块、同步锁及全局锁;有可能也会问到他们三者之间的区别,只要答到全局锁是锁当前类,同步锁是锁具体的对象就可以了

3、synchronized关键字的原理

当面试官问到关于synchronized原理的问题的时候,首先需要回答道代码反汇编生成monitorenter和monitorexit指令;其次再回答有关java对象布局的知识点,解释markword存储结构以及锁升级过程中标志位的变化。

当然对于java多线程的面试连环问题还不止这些,一般问到synchronized之后就是lock类、AQS原理、volatile关键字、线程池等等一系列的问题,之后的文章也会对这些问题一一解答。

在这里插入图片描述
在这里插入图片描述

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值