Java并发编程学习(二)——synchronized关键字

一.概述

Java并发编程学习(一)——线程一文中,我们详细了解了有关线程的相关话题,其中多次提到了synchronized这个关键字,今天我们就来聊一聊synchronized。

synchronized是java内置的一种用于实现线程间同步的简单、有效机制,是java提供给我们的内置锁。我们可以使用synchronized来修饰方法、代码块,使被修饰的代码一段时间内只能由一个线程进入,从而实现了共享变量被正确的并发访问。

二.相关概念

在进一步了解synchronized方法的使用之前,我们先来看几个有关概念:

线程安全
在《java并发编程实战》中,作者给出了这样的定义:

当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。

简而言之:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。

竞态条件
看完了线程安全的概念,我们可能会问,线程不安全的情况是如何产生的呢?

一种常见的情况就是:两个线程竞争同一资源时,并且对资源的访问顺序敏感,这种情况叫做存在竞态条件。

举个例子:

public class Counter {
    private int number;
    
    public int add() {
        return number++;
    }
}

在上面的add方法中,我们希望每次调用,number值都会加1,在单线程中执行,完全没有问题,但是在多线程时,结果有时会变得不可预料。

原因就在于,number++这一行代码在cpu中是由三条指令构成的:
(1)读取number的值放到寄存器;
(2)将寄存器的值+1;
(3)将寄存器的值写入number。

当有两个线程都需要执行add方法时,实际上cpu中就有6条执行需要执行,由于cpu会在不同的线程间切换,而这种切换的时机是未知的,因此6条指令的执行顺序很可能是交替进行的,从而导致了意向不到的结果。

根据我们上面的定义,上面的add方法(准确的说是number++语句)就存在竞态条件。

临界区
导致竞态条件发生的代码区域叫做临界区,上面Counter类中的add方法就是一个临界区。

可见性
可见性是多线程执行中的另一个重要问题,为了保证程序的正确执行,一个线程对共享变量的访问,应该及时被其他线程看到,这叫做内存可见性。

同样以上面的代码为例,如果不加任何的线程同步操作,那么当一个线程修改number时,其他线程并不能及时感知到,反而有可能拿到失效的结果,这就没有满足可见性。

上面我们谈到了线程安全,竞态条件&临界区和内存可见性,之所以提及这些概念,是因为它们是多线程并发执行中不容忽视的问题,也是java多线程机制引入背后的原因,而synchronized就是解决上面问题的一个最简单的方法。下面我们就来看看具体用法。

三.用法

大体来说,synchronized关键字有两种用法:修饰方法或者修饰代码块。

修饰方法
在java中,方法分为对象方法和类方法,synchronized可以修饰这两种方法。
(1)修饰对象方法

public class Counter {
    private int number;

    public synchronized int add() {
        return number++;
    }
}

为了使我们前面提到的Counter类线程安全,我们只需要使用synchronized关键字修饰add方法即可,很简单有没有?加上synchronized关键字后,访问某个Counter对象的所有线程只能互斥的进入add方法。
(2)修饰类方法

public class Counter {
    private static int value;

    public synchronized static int add() {
        return value++;
    }
}

修饰类方法同样是在方法声明中添加synchronized关键字,只是影响的范围不同:修饰静态方法时,将导致线程在访问Counter类的所有对象时,都将互斥的进入add方法,我们验证一下:

class CounterThread extends Thread {
    private Counter counter;

    public CounterThread(String name, Counter counter) {
        super(name);
        this.counter = counter;
    }
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + ",value=" + counter.add());
    }
}
public class Counter {
    private static int value;

    public static int add() {
        System.out.println(Thread.currentThread().getName() + "  begin add");
        try {
            Thread.sleep(2000); // 线程sleep时会让出cpu,但不会释放锁
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "  has sleep 2000 ms");
        return value++;
    }

    public static void main(String[] args) {
        Counter counter1 = new Counter();
        Counter counter2 = new Counter();
        CounterThread thread1 = new CounterThread("thread1", counter1);
        CounterThread thread2 = new CounterThread("thread2", counter2);
        thread1.start();
        thread2.start();
    }
}

在上面的代码中,我们定义了一个线程类,其中有一个Counter类的引用,用于执行add操作。

当前,add方法并没有加synchronized关键字,线程进入后,会休眠2秒钟,让出cpu让其他线程执行。

执行结果如下:

thread1  begin add
thread2  begin add
thread1  has sleep 2000 ms
thread1,value=0
thread2  has sleep 2000 ms
thread2,value=1

可以看出,thread2在thread1没有执行休眠完成前就进入了add方法,两个线程交替执行。

我们使用synchronized方法修饰add方法,再看看效果:

public static synchronized int add() {
        System.out.println(Thread.currentThread().getName() + "  begin add");
        ……
        return value++;
    }

执行结果如下:

thread2  begin add
thread2  has sleep 2000 ms
thread2,value=0
thread1  begin add
thread1  has sleep 2000 ms
thread1,value=1

可以看出,thread1在thread2退出add方法后,才进入,两个线程是同步的。虽然,两个线程中的Counter引用并不是同一个对象,但是由于add方法是静态方法,因此该类的所有对象的add方法都将被锁定,只有获得锁才可以进入。

修饰代码块
效果如下:

public class Counter {
    private int value;

    public  int add() {
        synchronized (this) {
            return value++;
        }
    }
}

被synchronized修饰的代码块需要获得锁才可以进入。效果与修饰方法相同,但是加锁的粒度更细,可以只将存在竞态条件的临界区加锁,其他不需要同步的代码不加锁,这样可以提高多线程的并发执行效率。

synchronized (this)中,this关键字指代当前对象,意味着,线程在执行该对象时,需要取得锁。

另外,还可以写synchronized (Counter.class),将使线程在执行Counter类的所有对象时,都将竞争锁。

无论是修饰方法还是代码块,都存在着对象锁和类锁的概念,对象锁只对执行该对象的线程有效,对不执行该对象的线程无效;类锁则对执行该类的所有对象的线程都有效。

打个比方,我们的家里有一个大门,进到家里每个房间都有自己的小门,大门有一把大锁,用于控制所有人的进入,每个小门的锁只控制进入该房间的人。大门的锁相当于类锁,房间的锁相当于对象锁,进入家里的人就是一个个的线程。

大门
房间门

四.实现

看起来,synchronized的使用非常简单,那么背后的实现原理是什么样的呢?

java虚拟机是通过Java对象头monitor这两者结合来实现synchronized同步的。

java对象头

jvm堆中创建的每个对象都包含以下几个区域:

java对象结构

对象头:记录了对象的hash码、锁信息、分代信息等;
实例变量:存储了对象中的属性信息;
填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。为了字节对齐,有时需要做字节填充

我们重点看对象头部分,对象头占2个字的内存空间(数组对象占3个字,多出来的一个字记录数组长度),包含以下两个部分信息:

Mark Word:存储对象的hashCode、锁信息或分代年龄或GC标志等信息。
Class Metadata Address:类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。

考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间。32位的jvm,随着对象的状态不同,可能的数据结构如下:

Mark Word

其中,轻量级锁和偏向锁是jdk 6.0新增的,之前只有重量级锁,当锁状态为重量级锁时,其中指针指向的是monitor对象,每个对象都存在着一个monitor对象与之关联。

monitor

在jvm中,monitor是由ObjectMonitor实现的(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的),其主要数据结构如下:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL; // 指向持有该对象的线程
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。

在了解了对象头和monitor之后,我们再来看synchronized加锁的实现原理:

  • 当一个线程想要进入synchronized代码块或者被synchronized修饰的方法时,就会改变对象头中锁状态,并设置monitor指针指向monitor对象;
  • 所有想要获得锁的线程首先会被放到_EntryList列表中;
  • 先判断monitor对象中的_count字段是否为0,如果为0,则说明当前对象没有被其他线程占用,将_count加1,,将_owner指向当前线程;
  • 当线程执行完毕,需要释放锁时,再将_count减1,将_owner置为null;
  • 在某个线程已经取得锁,并且还没有释放时,如果有其他线程尝试获得锁,就会被添加到_EntryList列表中,被阻塞,直到锁被释放再根据某种策略允许阻塞的线程进入。

至于类锁,个人理解加锁过程中操作的是类对应的Class对象。

五.jvm对synchronized的优化

由于monitor是依赖于操作系统底层的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。在jdk 1.6之前,synchronized就属于这种重量级锁,需要经常进行用户态到核心态的切换,效率不高,因此1.6中对synchronized进行了优化,引入了偏向锁、轻量级锁、自旋锁等概念。

偏向锁

经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。

轻量级锁

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

如下图所示:

锁升级
自旋锁

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。

我们上面说到,锁一共有四种状态:无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它们会随着竞争情况逐渐升级,但是不能够降级。

锁升级的过程大概是这样的,刚开始处于无锁状态,当线程第一次申请时,会先进入偏向锁状态,然后如果出现锁竞争,就会升级为轻量级锁(这升级过程中可能会牵扯自旋锁),如果轻量级锁还是解决不了问题,则会进入重量级锁状态,从而彻底解决并发的问题。

参考资料:

本文已迁移至我的博客:http://ipenge.com/25781.html

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,214评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,307评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,543评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,221评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,224评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,007评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,313评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,956评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,441评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,925评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,018评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,685评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,234评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,240评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,464评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,467评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,762评论 2 345

推荐阅读更多精彩内容

  • Java8张图 11、字符串不变性 12、equals()方法、hashCode()方法的区别 13、...
    Miley_MOJIE阅读 3,693评论 0 11
  • 1. Java基础部分 基础部分的顺序:基本语法,类相关的语法,内部类的语法,继承相关的语法,异常的语法,线程的语...
    子非鱼_t_阅读 31,581评论 18 399
  • 从三月份找实习到现在,面了一些公司,挂了不少,但最终还是拿到小米、百度、阿里、京东、新浪、CVTE、乐视家的研发岗...
    时芥蓝阅读 42,184评论 11 349
  • 1、 大概十年没见到哨兵了,听说已经当上连长了。前九年一直来信联系,最后一封信,我没有拆开看。 哨兵曾经提过最后悔...
    半个侧脸阅读 208评论 1 0