Java多线程之Synchronized篇章-02

Java多线程之Synchronized篇章

引言: 在多线程编程中,很常见的一个关键字就是synchronized,那么接下来就讲讲这个关键字的作用以及它的实现原理,最后列出一个应用demo。

目录:

  • Synchronized原理简述
  • Synchronized基础规则
  • 实例锁与全局锁
  • Synchronized的实现
  • 单例模式的隐患与解决之路
  • 善用synchronized

以上便是这一篇章的目录,不得不承认确实内容有点多,但是,我希望我能用简单易懂的语言与例子,让每一个读者真正认识Synchronized关键字。

一、Synchronized原理简述

首先,在java中,每个对象都有且仅有一个同步锁,这意味着同步锁是依赖于对象存在的。Synchronized就是通过获取同步锁实现的,当一个线程调用某对象的Synchronized修饰的方法时,就获取了该对象的同步锁,由于同步锁是互斥锁,也就是说,同一个时间点,对象的同步锁只能被一个线程获得,通过同步锁,我们就能够实现在多线程中,实现对 对象||方法的互斥访问,从而保证线程安全。

就比如: 线程A与线程B同时访问C对象的synchronized方法,这个时候假设线程A先获取该对象的同步锁,那么线程B就会等待A对该方法的调用结束,从而获取同步锁再调用该方法。

二、Synchronized基础规则

Synchronized的基础规则有三条:

① 当一个线程访问“某对象”的“synchronized方法”或者“synchronized代码块”时,其他线程对**“该对象”的该“synchronized方法”或者“synchronized代码块”的访问**将被阻塞。

② 当一个线程访问“某对象”的“synchronized方法”或者“synchronized代码块”时,其他线程仍然可以访问“该对象”的非同步代码块

③ 当一个线程访问“某对象”的“synchronized方法”或者“synchronized代码块”时,其他线程对**“该对象”的其他的“synchronized方法”或者“synchronized代码块”的访问**将被阻塞。

以下通过demo来理解这三个基础规则:

1、规则一

public class SynchronizedTest {
    public static void main(String[] args) {
        //测试rule one
        Runnable target = new MyRunnable();
        Thread t1 = new Thread(target, "t1");  // 新建“线程t1”, t1是基于target这个Runnable对象
        Thread t2 = new Thread(target, "t2");  // 新建“线程t2”, t2是基于target这个Runnable对象
        t1.start();                          // 启动“线程t1”
        t2.start();                          // 启动“线程t2”
    }
    private static class MyRunnable implements Runnable {
        @Override
        public void run() {
            synchronized (this) {
                try {
                    for (int i = 0; i < 5; i++) {
                        Thread.sleep(100); // 休眠100ms
                        System.out.println(Thread.currentThread().getName() + " loop " + i);
                    }
                } catch (InterruptedException ie) {
                }
            }
        }
    }
}

运行如上代码,结果如下:

t1 loop 0
t1 loop 1
t1 loop 2
t1 loop 3
t1 loop 4
t2 loop 0
t2 loop 1
t2 loop 2
t2 loop 3
t2 loop 4

结果的说明:run()方法中存在“synchronized(this)代码块”,而且t1和t2都是基于"target这个Runnable对象"创建的线程。这就意味着,我们可以将synchronized(this)中的this看作是“target这个Runnable对象”;因此,线程t1和t2共享“target对象的同步锁”。所以,当一个线程运行的时候,另外一个线程必须等待“运行线程”释放“target的同步锁”之后才能运行。

如果你理解了规则一,那么我们再做一个测试,如下:

//修改上面的main方法体
 public static void main(String[] args) {
        //测试rule one
        Runnable target = new MyRunnable();
        Runnable target2 = new MyRunnable();
        Thread t1 = new Thread(target, "t1");  // 新建“线程t1”, t1是基于target这个Runnable对象
        Thread t2 = new Thread(target2, "t2");  // 新建“线程t2”, t2是基于target2这个Runnable对象
        t1.start();                          // 启动“线程t1”
        t2.start();                          // 启动“线程t2”
    }

这个时候的结果是:

t2 loop 0
t1 loop 0
t1 loop 1
t2 loop 1
t2 loop 2
t1 loop 2
t1 loop 3
t2 loop 3
t2 loop 4
t1 loop 4

由于target与target2是不同对象,因此,两个线程获取的是不同的对象锁,因此并不需要等待,所以输出结果是交叉的。通过以上两个demo,相信读者对规则一有更深的理解了。

2、规则二

规则二是当一个线程获取了某对象的同步锁,调用synchronized的方法或者代码块时,其它线程仍然能够访问非同步的方法或者代码块。

public class SynchronizedRuleTwo {
    private synchronized void synMethod() {
        try {
            for (int i = 0; i < 5; i++) {
                Thread.sleep(100); // 休眠100ms
                System.out.println(Thread.currentThread().getName() + " synMethod loop " + i);
            }
        } catch (InterruptedException ie) {
        }
    }
    private void noSynMethod() {
        try {
            for (int i = 0; i < 5; i++) {
                Thread.sleep(100);
                System.out.println(Thread.currentThread().getName() + " nonSynMethod loop " + i);
            }
        } catch (InterruptedException ie) {
        }
    }
    public static void main(String[] args) {
        SynchronizedRuleTwo synchronizedRuleTwo = new SynchronizedRuleTwo();
        Runnable target0=()->{
            synchronizedRuleTwo.synMethod();
        };
        Runnable target1=()->{
            synchronizedRuleTwo.noSynMethod();
        };
        new Thread(target0,"t1").start();
        new Thread(target1,"t2").start();
    }
}

运行结果如下:

t2 nonSynMethod loop 0
t1 synMethod loop 0
t1 synMethod loop 1
t2 nonSynMethod loop 1
t2 nonSynMethod loop 2
t1 synMethod loop 2
t2 nonSynMethod loop 3
t1 synMethod loop 3
t2 nonSynMethod loop 4
t1 synMethod loop 4

证明了规则二,思路还是比较清晰的。

3、规则三

为了证明规则三,直接利用规则二的demo即可,修改如下:

public class SynchronizedRuleTwo {
    private synchronized void synMethod() {
        try {
            for (int i = 0; i < 5; i++) {
                Thread.sleep(100); // 休眠100ms
                System.out.println(Thread.currentThread().getName() + " synMethod loop " + i);
            }
        } catch (InterruptedException ie) {
        }
    }

    private synchronized void anotherSynMethod() {
        try {
            for (int i = 0; i < 5; i++) {
                Thread.sleep(100);
                System.out.println(Thread.currentThread().getName() + " nonSynMethod loop " + i);
            }
        } catch (InterruptedException ie) {
        }
    }

    public static void main(String[] args) {
        SynchronizedRuleTwo synchronizedRuleTwo = new SynchronizedRuleTwo();
        Runnable target0=()->{
            synchronizedRuleTwo.synMethod();
        };
        Runnable target1=()->{
            synchronizedRuleTwo.anotherSynMethod();
        };
        new Thread(target0,"t1").start();
        new Thread(target1,"t2").start();
    }
}

结果如下:

t1 synMethod loop 0
t1 synMethod loop 1
t1 synMethod loop 2
t1 synMethod loop 3
t1 synMethod loop 4
t2 nonSynMethod loop 0
t2 nonSynMethod loop 1
t2 nonSynMethod loop 2
t2 nonSynMethod loop 3
t2 nonSynMethod loop 4

即证明规则三,一个线程获取了对象的同步锁,访问了synchronized修饰的方法,其它线程也不能够访问该对象的其它使用了synchronized修饰的方法或者代码块。

三、实例锁与全局锁

与Synchronized相关的还有实例锁、全局锁,接下来就讲解什么是实例锁,什么是全局锁。

1、实例锁

  • 锁在某一个实例对象上,即实例锁。如果该实例是单例,那么该实例锁也具有全局锁的概念。
  • 对应 synchronized关键字

2、全局锁

  • 锁在类上,无论多少个实例对象,都共享该锁。
  • 对应 static synchronized,引用《java并发编程的艺术》里的一句话就是:“锁是当前类的Class对象”

记得去年看过一篇很好的文章,它列举了一个例子来说明:

pulbic class Something {
    public synchronized void isSyncA(){}
    public synchronized void isSyncB(){}
    public static synchronized void cSyncA(){}
    public static synchronized void cSyncB(){}
}

假设something有两个实例,X和Y,则有如下四种情况:

(01) x.isSyncA()与x.isSyncB()
(02) x.isSyncA()与y.isSyncA()
(03) x.cSyncA()与y.cSyncB()
(04) x.isSyncA()与Something.cSyncA()

(01) 不能被同时访问。因为isSyncA()和isSyncB()都是访问同一个对象(对象x)的同步锁!

(02) 可以同时被访问。因为访问的不是同一个对象的同步锁,x.isSyncA()访问的是x的同步锁,而y.isSyncA()访问的是y的同步锁。

(03) 不能被同时访问。因为cSyncA()和cSyncB()都是static类型,x.cSyncA()相当于Something.isSyncA(),y.cSyncB()相当于Something.isSyncB(),因此它们共用一个同步锁,不能被同时调用。

(04) 可以被同时访问。因为isSyncA()是实例方法,x.isSyncA()使用的是对象x的锁;而cSyncA()是静态方法,Something.cSyncA()可以理解对使用的是“类的锁”。因此,它们是可以被同时访问的。

在理解好实例锁与全局锁的基础上,才能更好地理解Synchronized关键字。

四、Synchronized的实现

OK,上面讲了Synchronized关键字的原理、规则,是时候揭开它的神秘面沙了,它是怎么实现的呢?

1、基础实现原理

通过JVM规范,我们可以了解到synchronized的实现原理,即JVM基于进入和退出Moniter对象来实现方法和代码块的同步,但是两者实现的细节不一。

  • 同步代码块:通过moniterenter和moniterexit指令,大概如下:
moniterenter  
if(moniter)
do something in syn;
moniterexit
  • 同步方法:JVM规范并没有说明是采用什么方式,但是通过moniterenter和moniterexit指令也能够实现方法的同步。

概述:每个对象都拥有一个moniter与之对应,当一个moniter被持有之后,它就属于锁定状态。当线程执行来到了moniterenter 指令,就会尝试去获取对象的moniter,如果该对象已经被持有,则会获取失败,等待其它线程执行完成释放了moniter,它就会重新获取并且持有,从而执行对应代码。

2、synchronized的锁藏在哪

synchronized也称为隐式锁,那么它的锁藏在哪里呢?原来,它用的锁是藏在java的对象头里面,下面对java的对象头做一个简单介绍。

一个JAVA对象头的构成:

  • Mark Word
  • 指向类的指针
  • 数组长度(只有数组对象才有)

而对应的锁的信息则是保存在Mark Word中,当一个对象中使用了synchronized关键字时,一系列的操作实质上都是在Mark Word中操作的。那么,MarkWord长什么样子呢?

在32bits的VM中,如下:

在这里插入图片描述
在64位的VM中,则如下:

在这里插入图片描述

对于其中的无锁、偏向锁、轻量级锁、重量级锁的讲解如下:

我们通常说的通过synchronized实现的同步锁,真实名称叫做重量级锁。但是重量级锁会造成线程排队(串行执行),且会使CPU在用户态和核心态之间频繁切换,所以代价高、效率低。为了提高效率,在JDK1.6之后,添加了锁升级机制,即上述的几种锁,不会一开始就使用重量级锁,JVM在内部会根据需要,按如下步骤进行锁的升级:

  • 初期锁对象刚创建时,还没有任何线程来竞争,对象的Mark Word是上图的第一种情形,这偏向锁标识位是0,锁状态01,说明该对象处于无锁状态(无线程竞争它)。
  • 当有一个线程来竞争锁时,先用偏向锁,根据线程的ID,表示锁对象偏爱这个线程,这个线程要执行这个锁关联的任何代码,不需要再做任何检查和切换,这种竞争不激烈的情况下,效率非常高。这时Mark Word会记录自己偏爱的线程的ID,把该线程当做自己的熟人。如上图第二种情形。
  • 当有两个线程开始竞争这个锁对象,情况发生变化了,不再是偏向(独占)锁了,锁会升级为轻量级锁,两个线程公平竞争,哪个线程先占有锁对象并执行代码,锁对象的Mark Word就执行哪个线程的栈帧中的锁记录。如上图第三种情形。
  • 如果竞争的这个锁对象的线程更多,导致了更多的切换和等待,JVM会把该锁对象的锁升级为重量级锁,这个就叫做同步锁,这个锁对象Mark Word再次发生变化,会指向一个监视器对象,这个监视器对象用数据结构的形式,来登记和管理排队的线程。如上图第四种情况。

3、自旋锁、轻量级锁、重量级锁

==这里要注意一点,锁可以升级,不能够降级。==下面将给读者讲解到底什么是轻量级锁,什么是重量级锁,在此之前,先了解一下自旋锁。

1.自旋锁

自旋锁顾名思义,就是采用自旋的方法获得锁,那么它是怎么实现的呢?

  • 当前线程竞争锁失败,打算阻塞自己时
  • 不直接阻塞自己,而是自旋(空等待,比如一个空的有限for循环)一会
  • 在自旋的同时重新竞争锁
  • 如果自旋结束前获得了锁,那么锁获取成功;否则,自旋结束后阻塞自己(升级为重量级锁)

2.轻量级锁

**轻量级锁的本质就是自旋锁,**之所以称之为轻量级锁,是相对于重量级锁而言。那么它为什么是轻量级的呢?

原因:

  • 采用CAS+自旋,不挂断、不阻塞当前线程。
  • 不需要申请互斥量(操作系统级别锁)仅仅将Mark Word中的部分字节CAS更新指向线程栈中的Lock Record

由于不需要申请互斥量,不引起内核态与用户态切换以及线程切换,从而损耗比重量级锁小。

3.重量级锁

重量级锁是直接对应底层操作系统中的互斥量(mutex),实质上:申请操作系统的互斥量,当一个线程持有该互斥量,其余线程阻塞自己。那么重在哪?

原因:

  • 阻塞当前线程
  • 申请互斥量而引起的内核态与用户态切换(主要)

总结:

  • 轻量级锁与重量级锁是相对而言的,轻量级锁实质是自旋锁加CAS;重量级锁直接对应操作系统层面的mutex互斥量。
  • 重量级锁会引起用户态与内核态的切换,消耗巨大。

用户态和内核态简单来讲就是:

运行在用户态下的 程序不能直接访问操作系统内核数据结构和程序。当我们在系统中执行一个程序时,大部分时间是运行在用户态下的,在其需要操作系统帮助完成某些它没有权力和能力完成 的工作时就会切换到内核态。

而这两种状态,其实就是创建一个进程,系统会为该进程分配两个堆栈,一个存于用户空间,称为用户栈;一个存于内核空间,称为内核栈;当程序在用户态运行的时候,CPU堆栈指针寄存器里面的内容是用户堆栈地址,使用用户栈;而当进程切换到内核态,则对应的CPU堆栈指针寄存器需要存放内核堆栈地址;引起这两种状态的一个切换就是运行在用户态下的程序为了完成某项功能需要调用到操作系统的来帮忙,调用内核的操作方法,这个时候就发生了切换。

五、单例模式的隐患与解决之路

OK,讲了那么多的原理和实现,我们也该讲讲应用了,今天这个应用即是单例模式。

安全隐患的懒汉模式单例:

public class UnsafeSingleTon {
    private static UnsafeSingleTon instance;
    
    public static UnsafeSingleTon getUnsafeSingleTon() {
        if (instance == null) {     //线程1
            instance = new UnsafeSingleTon();//线程2
        }
        return instance;
    }
}

以上的代码,在线程1执行 if (instance == null)时,线程2执行了 instance = new UnsafeSingleTon();,因此造成了两次new,则违背了单例的创建思想。

那么,有人提出了修改为:

 public static synchronized UnsafeSingleTon getSafeSingleTon(){
        if (instance == null) {
            instance = new UnsafeSingleTon();
        }
        return instance;
    }

确实,上述的代码是做到了代码的线程安全,消除了安全隐患,但是,带来了的是性能的损耗,你想想,但许多的线程同时访问该方法,造成的开销是多么的大,这是一个开销隐患。

那么,如何既解决安全隐患,又解决开销隐患呢?

早期人们的一种错误方式:

public static UnsafeSingleTon getSafeSingleTon(){
        if (instance == null) {
            synchronized(UnsafeSingleTon.class){
        	if(instance == null)
            instance = new UnsafeSingleTon();//问题出现在此。
            }
        }
        return instance;
    }

instance = new UnsafeSingleTon()可以拆分为:

  • memory=allocate();//1
  • ctorInstance(memory);//2初始化对象
  • instance=memory;//3赋值引用

而JVM会进行指令的重排序,2和3是有可能发生颠倒的,则返回的instance不为空,却也未执行初始化,这个时候其它线程刚好判断到instance不为空,就获取使用了,因此存在安全隐患。

那么如何解决呢?有两种方式,一种是volatile修饰法,一种是改为饿汉单例模式。

  • volatile修饰法
public class UnsafeSingleTon {
    private static volatile UnsafeSingleTon instance;
    
    public static UnsafeSingleTon getsafeSingleTon() {
        if (instance == null) {
            synchronized(UnsafeSingleTon.class){
        	if(instance == null)
            instance = new UnsafeSingleTon();//根据happens-before规则禁止指令重排序
            }
        }
        return instance;
}
  • 饿汉模式
public class UnsafeSingleTon {
    private static UnsafeSingleTon instance=new  UnsafeSingleTon();
    public static UnsafeSingleTon getsafeSingleTon() {
        return instance;
}

如果读者对于volatile不够了解,可以看我的另一篇博文《volatile与cas的原理》。

六、善用Synchronized

对于Synchronized,理解它的原理,是为了使用它,而使用它不仅仅只是会用,我们也要学会善用。

这里主要是讲Synchronized锁方法与Synchronized锁代码块的用法:

  • 方法上
    • 缺点:同步的位置或者资源不精准,有时候效率没有锁代码块高
    • 优点:简洁、易用
  • 代码块
    • 缺点:不够简洁,使用者需要准确加锁
    • 优点:同步的位置或者资源足够精准,效率可能比较高

上面讲了效率的问题,以下用伪代码做一个简单的说明:

//A为一个共享资源

private synchronized void synMethod(){
  prepare some resource;
  do some tasks;
  dosomething in A;
}

private  void synBloked(){
  prepare some resource;
  do some tasks;
  synchronized(A){
 	 dosomething in A;   
  }
}

以上的两种用法,效率的体现就在于:多线程调用 synMethod(),第一个线程持有对象锁,其它线程需要等待,等待的过程就是执行完:prepare some resource、 do some tasks、 dosomething in A;假设每个耗时都为N,则t=3N。而调用 synBloked(),第一个线程执行到 synchronized(A)才持有对象锁,此时第二个线程也都执行完前两步,等待的时间为:dosomething in A,耗时t=N。

因此,使用Synchronized需要根据实际的情况决定是锁代码块还是锁方法。

发布了57 篇原创文章 · 获赞 32 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/rekingman/article/details/90047167