理解synchronized机制

181 阅读10分钟

前言

让时光积累真正的价值

在面试的一次经历中,有一道题目是这样的请描述一下Java1.6对synchronized的优化。这是哪跟哪呀,在我的认知中synchronized就是加在方法上的关键字。能有这么复杂的东西吗?害,说出来确实很丢人的。。

我在找资料的时候发现了一篇写得不错的博客,归纳了这一篇笔记。

附上原文地址:

深入理解Java并发之synchronized实现原理:https://blog.csdn.net/javazejian/article/details/72828483

为什么需要synchronized

在Java内存模型JMM 下存储模型被分为共享的主内存和线程私有的工作内存,线程只能操作工作内存的数据,JVM通过拷贝的方式将数据从主内存复制到工作内存。在这个过程当中会产生多个数据副本。在多个线程对主内存的公共数据同时访问下很容易出现线程安全问题。为了解决线程安全的的问题Java提供了可重入锁和synchronized关键字。对这个不熟悉的同学可以参考java内存模型


使用synchronized

synchronized的三种使用方式

synchronized有3种使用方式分别是修饰对象方法、修饰静态方法、修饰代码块。

修饰方法

修饰对象方法是synchronized的一种常使用方式。除了可以用来修饰对象的方法,synchronized还可以用来修饰静态方法。在使用了synchronized的方法以后,同一个时刻只能有一个线程访问这个方法。值得注意的是,当一个对象有一个以上的使用synchronized修饰的方法时,这些方法之间是不能同时执行的。

 1public class Task implements Runable{
2
3    //线程共享的资源
4    int i = 0;
5
6    //使用synchronized的方法
7    private synchronized void sync(){
8        i+=1;
9    } 
10
11    @Override
12    public void run(){
13        for(int i=0;i<100;i++)
14            sync();
15    }
16
17    public static void main(String [] args){
18        Task task = new Task();
19        Thread t1 = new Thread(task);
20        Thread t2 = new Thread(task);
21        t1.start();t2.start();
22
23        //等待线程执行完成
24        t1.join();t2.join();
25
26         System.out.println(task.i);
27        //查询最后输出200
28    }
29}
修饰静态方法

synchronized可以用来修饰一个静态的方法。和修饰对象方法类似,被修饰的静态方法在同一个时刻只允许一个线程访问。

 1public class Task implements Runable{
2
3    //线程共享的资源
4    static int i = 0;
5
6    //使用synchronized的方法
7    private static  synchronized void sync(){
8        i+=1;
9    } 
10
11    @Override
12    public void run(){
13        for(int i=0;i<100;i++)
14            sync();
15    }
16
17    public static void main(String [] args){
18        Task task = new Task();
19        Thread t1 = new Thread(task);
20        Thread t2 = new Thread(task);
21        t1.start();t2.start();
22
23        //等待线程执行完成
24        t1.join();t2.join();
25
26         System.out.println(task.i);
27        //查询最后输出200
28    }
29}
修饰对象/修饰代码块

synchronized 关键字可以修饰一段代码块,在使用时需要锁住一个对象。程序访问到有synchronizedg的代码块时会先尝试获取对象的锁,如果获取到对象锁就可以进入代码块执行对应的逻辑,如果锁被其他线程获取,则会这个线程堵塞等待,直到被唤醒为止。这里需要注意的是:要是一个线程通过给对象加锁了以后,另外一个线程通过不获取锁的方式来读写这个对象是被允许的。

 1public class Task implements Runable{
2
3    //线程共享的资源
4    //需要注意的是这里需要使用封装类型
5    Integer i = 0;
6
7    @Override
8    public void run(){
9        for(int i=0;i<100;i++){
10
11            //给对象上锁
12            synchronized(i){
13                i++;
14            }
15        }
16
17    }
18
19    public static void main(String [] args){
20        Task task = new Task();
21        Thread t1 = new Thread(task);
22        Thread t2 = new Thread(task);
23        t1.start();t2.start();
24
25        //等待线程执行完成
26        t1.join();t2.join();
27
28         System.out.println(task.i);
29        //查询最后输出200
30    }
31}

理解Java对象头与Monitor

synchronized的实现和Java对象头和Monitor对象有很大的关系。想要了解synchronized背后的原理,了解一下Java对象头和Monitor对象是很有必要的。

Java对象头
对象的结构

一个保存在Java内存中的对象主要包含了3部分的数据分别是对象头、实例变量、填充数据(如图)

  1. 实例变量:存储了类的属性完整的变量信息(包括父类的变量信息),如果属性是数组对象还要保存数组的长度,这些数据以4个字节对齐。

  2. 填充数据:根据对象的规范,类的大小必须是8字节的整数倍,当对象头和实例变量的大小相加不是8 的整数倍使用填充数据补齐。关于补齐:虚拟机要求的是地址补齐,也就是说对象的地址必须是8的整数倍

详解头信息

Java的对象头主要有两个部分:类元数据地址(class metadata address)、Mark Word组成。

  1. class Metadata Address :标记对象所属的类加载数据地址,占位1个字。

每个Java对象都有一个getClass的方法,这个方法就是将这里的数据返回的

  1. Mark word(标记信息):记录的锁信息,GC信息(年龄信息),hashcode

这个区域保存的是类的标记信息数据。除了和锁相关是数据之外还存储这对象的hashcode、还有GC信息。这些都是和程序息息相关的数据。

从上面的信息中我们可以了解到,锁的实现和Java对象头里面的Mark Word有很大的关系

Mark Word中有很很多与锁相关的信息(如图)


其中锁状态指向了一个一个ObjectMonitor对象。ObjectMonitor是用来控制线程访问锁的。

Monitor对象

Monitor对象又称为管程或者监视器锁,每个对象都一个指针指向Monitor对象。Object和Monitor对象关联有多种方式,可以在伴随着Object对象的生命周期创建和销毁对应的Monitor对象,也可以在线程尝试锁定对象的时候创建Monitor对象。

在虚拟机中,Monitor对象是由C++ 语言来实现的,位于HotSpot虚拟机源码ObjectMonitor.hpp中。

其中Monitor对象和线程获取锁、堵塞、等待、唤醒相关的字段有owner、entryList、waitset。

  1. 线程尝试获取对象的锁,如果对象能获取到锁,线程直接进入owner。
  2. 如果线程获取不到锁,线程进入entryList中等待。
  3. 当owner中的线程调用Java对象的wait方法时,线程进入waitset中等待唤醒。
  4. 进入waitset被唤醒才能重新进入owner。

Java6对synchronized的优化

使用synchronized关键字来实现的锁是一个重量级的锁。在Java1.6以后Java对synchronized做了一定的优化。这些优化内Java的内部优化,用户不需要对代码做任何修改就可以享受到优化带来性能上的提升。

偏向锁

大多数情况下,锁总是一个线程重复获取,并不存在竞争的情况。偏向锁就是假设线程都是这样的模式来获取锁的。

线程第一次获取锁的时,Mark Word的锁结构转变为偏向锁模式。当同一个线程再次获取锁的时,无需操作直接获取锁,这样节省了获取锁的开支。当存在另外一个线程获取锁时,偏向锁会失败,这时锁会升级为轻量级锁。

轻量级锁

在多个线程获取锁的情况下,一般来说也是多个线程交替获取锁的,也就是说同一个时间里面不会有多个线程在竞争锁。轻量级锁的做法是这样的,在偏向锁失败了以后JVM会将锁升级为轻量级锁,Mark Word 的锁结构转化为轻量级锁结构。如果同一时间内有不同的线程争抢锁,这时轻量级锁会升级为普通的重量级锁。

自旋锁

轻量级锁失败以后,线程争抢锁失败。有可能之前获取到锁的线程短时间内已经执行完毕了。为了对这种情况进行优化,在线程获取锁失败时,JVM不会对马上让线程进入待命状态,而是会让线程多跑几个空循环,同时不断尝试继续获取锁,如果线程在几个循环之后获取到锁线程马上进入运行状态。在一定的循环之后线程没有获取到锁,JVM才让线程进入待命状态。

锁消除

在加了synchronized的代码中,有可能不会有多个线程对这段代码进行访问。在代码编译的时候编译器可以通过代码分析,找出这样的代码,并将这些没用到的锁消除掉。

总的来说Java对代码的优化如下图