深入理解synchronized关键字

操作系统层面的线程知识

在讲解synchronized的原理之前,我们先来讲讲操作系统层面的线程知识。我们来看看操作系统是怎么创建一个线程的又是怎么保证线程同步的(这里的操作系统指的是Linux系统)。
这里先普及一个知识,Linux操作系统创建线程的函数是:

pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);

关于这个函数可以在Linux上用man手册查看:
在这里插入图片描述
​可以看到pthread_create有四个参数,下面讲讲这四个参数的含义:

参数 含义
pthread_t *thread 这是一个传出参数,创建线程后会传出被创建的线程id
const pthread_attr_t *attr 线程的属性(这是Linux的范畴,这里就不作深入,使用默认属性传null即可)
void *(*start_routine) 线程启动后的主体函数,就是这个线程用来干什么的。(这里可以想象为java的run方法(实际上不是))。
void *arg 主体线程的参数,没有则传null

了解了参数的含义之后,我们就动手来试试手写一个c文件调调这个函数:

#include <pthread.h>
#include <stdio.h>
//定义变量接受线程id
pthread_t pid;
//线程的主体方法相当于 java当中的run
void* thread_entity(void* arg) {
   
  //子线程死循环
  while(1){
   
    //睡眠1000毫秒
    usleep(1000);
    //打印
    printf("I am new Thread\n");
  }
}
//c语言的主方法入口方法,相当于java的main
int main() {
   
  //调用linux的系统的函数创建一个线程
  pthread_create(&pid,NULL,thread_entity,NULL);
    //主线程死循环
    while(1){
   
    //睡眠1000毫秒
    usleep(1000);
    //打印
    printf("I am main\n");
  }
}

然后执行下面的命令进行编译:

gcc thread.c -o thread.out -pthread

编译后会产生一个thread.out的文件:
在这里插入图片描述
执行效果如下:
在这里插入图片描述
接下来就到我们的重点函数——pthread_mutex_lock,这个函数的主要作用就是调用操作系统内核实现线程互斥(java叫同步),后面证明java虚拟机调的就是代码pthread_mutex_lock,如下:

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
​
int sharei = 0;
void increase_num(void);//定义一个函数变量
​
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//创建一把锁
​
​
​
//c语言的主方法入口方法,相当于java的main
int main() {
   
  int ret;
  //定义三个线程
  pthread_t thread1,thread2,thread3;
  ret = pthread_create(&thread1,NULL,(void *)&increase_num,NULL);
  ret = pthread_create(&thread2,NULL,(void *)&increase_num,NULL);
  ret = pthread_create(&thread3,NULL,(void *)&increase_num,NULL);
 
  //类似java的join
  pthread_join(thread1,NULL);
  pthread_join(thread2,NULL);
  pthread_join(thread3,NULL);printf("sharei = %d\n",sharei);return 0;
}void increase_num(void)
{
   
  long i,tmp;
  for(i=0;i<=9999;i++)
  {
   
   //加锁
   pthread_mutex_lock(&mutex);
​
   tmp=sharei;
   tmp=tmp+1;
   sharei = tmp;pthread_mutex_unlock(&mutex);
  }
}

执行结果:
在这里插入图片描述
如果不加锁的执行结果(即注释掉第37和43行代码):
在这里插入图片描述
你会看到,如果不加锁每次执行的结果都不一样并且都是一个少于等于30000。为什么呢?因为线程在并发执行的时候很可能存在重复加的操作,举个例子,假设t1线程拿到sharei的值是1然后赋值给tmp,并且对tmp+1,这时t1线程的时间片到了轮到t2执行,t2拿到的sharei也是1,然后也赋值给tmp并加1,最后把结果赋值给了sharei。这时t2线程的时间片也到了轮到t1执行,t1也把刚刚到到的结果2赋值给了sharei,最后sharei应该是3的结果变成了2。所以最终的结果很可能会少于30000。
而加了pthread_mutex_lock则保证了 tmp=sharei;tmp=tmp+1;sharei = tmp;这三行代码是原子性的,要么全成功,要么全失败。而jdk1.5以前就是采用pthread_mutex_lock来保证线程安全的。
但是这样做会有一个致命的弱点,那就是性能问题。很多时候我们加synchronized的方法或者代码块都不存在线程安全问题,也就是说很多情况下都是只有一个线程在执行,如果每个加了synchronized关键字的地方都调用一次mutex这个函数去让线程睡眠是非常耗性能的。为什么mutex这么耗性能呢?原来操作系统为了保证安全,对一些它认为比较危险的操作只能通过api调用而不能直接调你写的c代码,而这个api调用就会把操作系统的状态从用户态切换到内核态,这个切换的过程就是一个很慢的过程(对计算机来说),因为在切换的时候涉及到一个虚拟地址的保存,内核态切换到用户态CPU又要把这个虚拟地址还原(通俗点来说就是切换到内核态要把CPU执行哪条指令要保存起来,内核态切换为用户态时又要把这条指令找出来CPU才能继续执行)。也就是说一个线程过来加锁要有一次系统调用,释放锁也要有一次系统调用,所以导致synchronized的性能非常差,所以使用mutex被称为重量锁。
既然mutex是重量级锁,下面我们就来看看java虚拟机后面是如何对synchronized进行优化的。

java对象头结构以及加锁标志

我们都知道synchronized锁是给java对象加的(相信做java的不会不知道synchronized是对一个Java对象上锁吧)。既然是对java对象上锁,我们就要弄懂一个java对象是由什么组成的。为了让大家更加清晰的看到java对象的内存结构,我这里就用一个openjdk的ClassLayout这个类来打印一个java对象的内存结构。
首先在pom文件里添加这个类的依赖:

        <dependency>
            <groupId>org.openjdk.jol</groupId>
            <artifactId>jol-core</artifactId>
            <version>0.9</version>
        </dependency>

然后定义一个类A,然后使用ClassLayout打印出这个A类对象的内存结构:

public class A {
   
}public static void main(String[] args) {
   
      A a = new A();
      System.out.printf(ClassLayout.parseInstance(a).toPrintable());
 }

运行结果如下:

aTest.A object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           43 c1 00 20 (01000011 11000001 00000000 00100000) (536920387)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
​
Process finished with exit code 0

借助ClassLayout可以清晰的看到,对象头占了12bytes(4+4+4),对齐填充占了4bytes,所以整个对象就16bytes(16bytes*8=96bit)。当然了,这样看还不能看出什么结果,所以我们来看看比较权威的jvm规范文档:http://openjdk.java.net/groups/hotspot/docs/HotSpotGlossary.html。

可以看到这个描述:

object header
      Common structure at the beginning of every GC-managed heap object.
      (Every oop points to an object header.) Includes fundamental information 
      about the heap object's layout, type, GC state, synchronization state, and identity hash code. 
      Consists of two words. In arrays it is immediately followed by a length field.
       Note that both Java objects and VM-internal objects have a common object header format.

由于官方文档是英文写的,我试着翻译一下吧(我的英语不太好,翻译不对的地方还请多多包涵)
首先第一句讲的是对象头是被gc管理的堆对象(这里可以理解为java的对象)的公共部分,这个对象头包含了type、gc的状态、synchronization 的状态(加锁状态)还有本身的hash code。而且这个对象头有两个words,我们试着来找找这两个words的描述。
首先是第一个words——mark word:

mark word
  The first word of every object header.
   Usually a set of bitfields including synchronization state and identity hash code.
   May also be a pointer (with characteristic low bit encoding) to synchronization related information.
   During GC, may contain GC state bits.

通过文档我们找到mark word就是对象头的第一个word,看描述就知道它包含了synchronization state、hash code和gc状态标志,至于具体是怎样分配的不同的虚拟机会有不同的实现。(后面再来重点分析)
然后看第二个word——klass pointer:

klass pointer
    The second word of every object header. 
    Points to another object (a metaobject) which describes the layout and behavior of the original object. 
    For Java objects, the "klass" contains a C++ style "vtable".

而klass pointer则是第二个word(也就是Object header里面介绍的type),这里说这个klass pointer指向的是描述原始对象布局和行为的另一个对象(元对象)。其实就是一个指针指向我们的类模板,这个也好理解,我们的对象肯定要知道是属于哪个类的,就比如a对象要有个地方存储它的类型A,而这个指针就是用来指向A对象的。

结合ClassLayout打印出来的对象信息可以看到,从第8个字节开始存的就是就是klass pointer。这个klass pointer的大小不一定,如果开启了指针压缩就是4个bytes,没有开启则是8个bytes。而mark word就是前面的8个bytes(也就是64个bit)。
在这里插入图片描述
现在我们重点来分析一下mark word,我先给大家画个表格:

细心的你会发现,它说前面的25位是没有用到的(刚new出来的对象肯定没锁),而上面的结果第8位是1,是不是有点晕,难道这个表是错的?这个是不是错的还是要验证一下,我们看看如果这个对象存了hash code的值是不是能和这个表对的上。代码很简单,就加多个调用hashcode的方法:

    public static void main(String[] args) {
   
        A a = new A();
        System.out.println(Integer.toHexString(a.hashCode()));
        System.out.println(ClassLayout.parseInstance(a).toPrintable());
    }

在这里插入图片描述
通过打印我们看到这个对象的hash值是15 40 e1 9d(转成16进制后),而对象头里刚好有个值9d e1 40 15,是不是很像我们的hash值,但为什么是倒过来的?这是因为我的计算机的CPU是小端存储的,也就是高字节存在高地址,低字节存在低地址。小端存储读取这个头信息的时候却是从低字节开始读的,举个例子:假设要存00000001 00000000这个到CPU内存按照高字节存高位,低字节存低位那么存到内存就是00000000 00000001(左边是低位),所以我们看到的hashcode的才是倒过来的。现在我们再来对比表格看这个对象头,它说前25位是没有用到,的确看到第二行有25个0,也就是这25位的确没被用到。然后跟着会有31位的hash code,的确可以看到11011011 00111110 00000010 01001111 这32位存的是一个hash值,然后有个0是没用到所以剩下的就是31位。再来看最后的8位,有一位没用到所以剩下7位,然后有4位存储对象的年龄,这也是为什么对象的最大年龄是15(2^4-1)。 然后有1位存储偏向标志,2位存锁状态。结合对象看,这里是001,也就是说他是一个无锁不可偏向的状态。

      0     4        (object header)                           01 db 3e 02 (00000001 11011011 00111110 00000010) (37673729)
      4     4        (object header)                           4f 00 00 00 (01001111 00000000 00000000 00000000) (79)
      8     4        (object header)                           43 c1 00 20 (01000011 11000001 00000000 00100000) (536920387)
     12     4        (loss due to the next object alignment)

这里思考个问题,为什么这个a对象在调用了hashCode后状态会改成无锁不可偏向了呢?其实从那个对象头的图中可以看出,hashcode的存储地方和偏向锁的偏向地址是冲突的,也就是说当你调了hashcode方法后这个对象就没法存储偏向的线程id,因此虚拟机的做法就是直接禁用偏向锁。OK,既然这样那把调用hashcode的代码注释掉a对象应该就是个无锁可偏向的状态了吧。但是运行后的结果依然得到的是001,为什么呢?(不信,你可以自己试试)原理java虚拟机在启动的时候就把偏向锁延迟了,因为java虚拟机在启动的时候很多运行很多synchronized的代码,而这些锁基本上不是偏向锁,如果每个锁都要从偏向锁升级为轻量锁会比较消耗性能导致启动速度变慢,所以java虚拟机默认把偏向锁给延迟了4s钟。当然这个延迟偏向可以通过下面这个jvm参数关掉:

-XX:BiasedLockingStartupDelay=0

当我加上这个参数后的运行结果:
在这里插入图片描述
在这里插入图片描述
可以看到结果就是101无锁可偏向,至于它到底有没偏向除了看这三位还要看那54位有没有存一个线程id。从上图中看到那54位存的都是0,所以这个对象的状态是无锁可偏向。下面我们来试一下让它偏向一个线程,代码很简单加上synchronized就行:

    public static void main(String[] args) {
   
        A a = new A();
        synchronized (a){
   
            System.out.println(ClassLayout.parseInstance(a).toPrintable());
        }
    }

结果:

aTest.A object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           05 40 49 03 (00000101 01000000 01001001 00000011) (55132165)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           43 c1 00 20 (01000011 11000001 00000000 00100000) (536920387)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

现在可以看到偏向锁以及锁标志是101,40 49 03 存的就是这个线程的id。
下面就来看看轻量锁,轻量锁的演示很简单,就是加多个线程来加锁:

public static void main(String[] args) {
   
        final A a = new A();synchronized (a){
   
//            System.out.println(ClassLayout.parseInstance(a).toPrintable());
        }new Thread(new Runnable() {
   
            public void run() {
   
                synchronized (a){
   
                    System.out.println(ClassLayout.parseInstance(a).toPrintable());
                }
            }
        }).start();
    }

结果可以看到锁标志变成了000轻量锁:

OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           18 f2 05 1c (00011000 11110010 00000101 00011100) (470151704)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           43 c1 00 20 (01000011 11000001 00000000 00100000) (536920387)
     12     4        (loss due to the next object alignment)

下面演示一下重量锁:

public static void main(String[] args) throws InterruptedException {
   
        final A a = new A();
//        System.out.println(Integer.toHexString(a.hashCode()));new Thread(new Runnable() {
   
            public void run() {
   
                synchronized (a){
   
                    try {
   
                        Thread.sleep(200);//主要是让线程发生竞争
                    } catch (InterruptedException e) {
   
                        e.printStackTrace();
                    }
                    System.out.println(ClassLayout.parseInstance(a).toPrintable());
                }
            }
        }).start();synchronized (a){
   
            Thread.sleep(1000);
//            System.out.println(ClassLayout.parseInstance(a).toPrintable());
        }}

结果:

 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           ca 5f 2e 18 (11001010 01011111 00101110 00011000) (405692362)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           43 c1 00 20 (01000011 11000001 00000000 00100000) (536920387)
     12     4        (loss due to the next object alignment)

当线程发生竞争的时候,我们的对象锁就会升级为010重量锁。
看到这里我们就可以做个简单的总结(这里只是简单的做个总结,还没讲完):

1、如果偏向延迟被关闭了或者已经过了偏向延迟时间,那么刚new出来的对象的锁状态是无锁可偏向,
   如果这个时候有个线程对他加锁或者对这个对象加锁释而且放了锁并且再加锁时,这个对象的状态就会偏向该线程。
2、如果偏向延迟没有关闭,那么main方法new出来的对象的偏向锁会被禁用(无锁不可偏向),
3、如果偏向延迟关闭了,但是调用了这个对象的hashCode方法,那么这个对象的偏向锁也会被禁用(无锁不可偏向)
4、如果一个对象的锁状态是偏向锁、不可偏向无锁并且有两个交替执行的线程来加锁的时(注意这里交替执行指的是synchronized里面的代码块不用让线程睡眠或者其他操作就能交替执行),
   该对象的锁状态就会升级为轻量锁。
5、当两个线程不是交替执行也就是发生资源竞争的时候,这个对象的锁状态会升级为重量级锁

synchronized的底层原理

下面我们来讲解一下比较难的synchronized锁的膨胀过程(因为synchronized没有java级代码,所以只能看c++是怎么实现的)。
首先,我们来看一个非常简单的代码:

public class Test {
   
    static Object o = new Object();public static void main(String[] args) {
   
        synchronized (o){
   }
    }
}

然后用IDEA来查看一下这个代码的java字节码(不知道怎么看的可以百度)

public class sync.Test {
  static java.lang.Object o;
​
  public sync.Test();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return
​
  public static void main(java.lang.String[]);
    Code:
       0: getstatic     #2                  // Field o:Ljava/lang/Object;
       3: dup
       4: astore_1
       5: monitorenter
       6: aload_1
       7: monitorexit
       8: goto          16
      11: astore_2
      12: aload_1
      13: monitorexit
      14: aload_2
      15: athrow
      16: return
    Exception table:
       from    to  target type
           6     8    11   any
          11    14    11   any
​
  static {};
    Code:
       0: new           #3                  // class java/lang/Object
       3: dup
       4: invokespecial #1                  // Method java/lang/Object."<init>":()V
       7: putstatic     #2                  // Field o:Ljava/lang/Object;
      10: return
}

当代码中有synchronized关键字的时候,反编译的字节码会看到有个monitorenter和monitorexit的指令(最后一个monitorexit指令是抛异常的时候执行)。而这个命令会被java虚拟机的c++代码解析(虚拟机的开源代码可以自行到官网下载),这条指令会被一个叫bytecodeInterpreter.cpp的文件解析。在这个cpp文件里我们可以看到大概在1696行有这样的一条注释:

/* monitorenter and monitorexit for locking/unlocking an object */

这说明这行注释下的代码就是用来解析monitorenter和monitorexit指令的,我们先看解析monitorenter的代码(先预览一下,后面会拆分讲解):

CASE(_monitorenter): {
   
        oop lockee = STACK_OBJECT(-1);
        // derefing's lockee ought to provoke implicit null check
        CHECK_NULL(lockee);
        // find a free monitor or one already allocated for this object
        // if we find a matching object then we need a new monitor
        // since this is recursive enter
        BasicObjectLock* limit = istate->monitor_base();
        BasicObjectLock* most_recent = (BasicObjectLock*) istate->stack_base();
        BasicObjectLock* entry = NULL;
        while (most_recent != limit ) {
   
          if (most_recent->obj() == NULL) entry = most_recent;
          else if (most_recent->obj() == lockee) break;
          most_recent++;
        }
        if (entry != NULL) {
   
          entry->set_obj(lockee);
          int success = false
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值