在上一篇博客中,我“蜻蜓点水”般的介绍了下Java内存模型,在这一篇博客,我将带着大家看下Synchronized关键字的那些事,其实把Synchronized关键字放到上一篇博客中去介绍,也是符合
“Java内存模型”这个标题的,因为Synchronized关键字和Java内存模型有着密不可分的关系。但是这样,上一节的内容就太多了。同样的,这一节的内容也相当多。

概念

  • Synchronized在JVM的实现原理,基于进入和退出Monitor对象来实现方法同步和代码块同步
  • JVM通过 monitorenter 和 monitorexit 两个指令来实现
  • monitorenter指令是在编译后插入到同步代码块开始位置;
    monitorexit指令是插入到方法结束处和异常处;JVM要保证每个monitorenter必须有对应的monitorexit与之配对
  • 任何对象都有一个monitor与之关联:
    1.当且一个monitor被持有后,对象处于锁定状态
    2.线程执行到monitorenter指令时,尝试获取对象对应的monitor的所有权,即尝试获取对象的锁

以下内容整理自互联网,仅用于个人学习

1 Synchronized同步方法

好了,废话不多说,让我们开始吧,

表现为以下三种形式

  • 对于普通同步方法,锁是当前实例对象
  • 对于静态同步方法,锁是当前类的Class对象
  • 对于同步方法块,锁是Synchronized括号里配置的对象

1.1 述

  • **“线程安全”就是以获得的实例变量的值是经过同步处理的,不会出现脏读的现象。
  • “非线程安全”问题存在于“实例变量”中,如果是方法内部的私有变量,则不存在“非线程安全”问题,所得结果也就是“线程安全”的了。
  • 方法中的变量不存在非线程安全问题,永远都是线程安全的。这是方法内部的变量是私有的特性造成的。

  • 如果多个线程共同访问1个对象中的实例变量,则有可能出现“非线程安全”问题。

  • 两个线程同时访问一个没有同步的方法,如果两个线程同时操作业务对象中的实例变量,则有可能会出现“非线程安全”问题
  • 在两个线程访问同一个对象中的同步方法时一定是线程安全的。

  • 两个线程分别访问同一个类的两个不同实例的相同名称的同步方法,效果却是以异步的方式运行的.
    关键字synchronized取得的锁都是对象锁,而不是把一段代码或方法(函数)当作锁,所以在上面的示例中,哪个线程先执行带synchronized关键字的方法,哪个线程就持有该方法所属对象的锁Lock,那么其他线程只能呈等待状态,前提是多个线程访问的是同一个对象。

  • 但如果多个线程访问多个对象,则JVM会创建多个锁。调用用关键字synchronized声明的方法一定是排队运行的。
  • 虽然线程A先持有了object对象的锁,但线程B完全可以异步调用非synchronized类型的方法。

  • 1)A线程先持有object对象的Lock锁,B线程可以以异步的方式调用object对象中的非synchronized类型的方法。

  • 2)A线程先持有object对象的Lock锁,B线程如果在这时调用object对象中的synchronized类型的方法则需等待,也就是同步。

  • 发生脏读的情况是在读取实例变量时,此值已经被其他线程更改过了。

Synchronized基本使用

首先从一个最简单的例子开始看:

public class Main {    private int num = 0;    private void test() {        for (int i = 0; i < 50; i++) {            try {                TimeUnit.MILLISECONDS.sleep;            } catch (InterruptedException e) {                e.printStackTrace();            }            num++;        }    }    public static void main(String[] args) {        Main main = new Main();        for (int i = 0; i < 20; i++) {            new Thread -> {                main.test();            }).start();        }        try {            TimeUnit.SECONDS.sleep;            System.out.println;        } catch (InterruptedException e) {            e.printStackTrace();        }    }}

Main方法中开启了20个线程,每个线程执行50次的累加操作,最后打印出来的应该是50*20,也就是1000,但是每次打印出来的都不是1000,而是比1000小的数字。相信这个例子,大家早就烂熟于心了,对解决方案也是手到擒来:

public class Main {    private int num = 0;    private synchronized void test() {        for (int i = 0; i < 50; i++) {            try {                TimeUnit.MILLISECONDS.sleep;            } catch (InterruptedException e) {                e.printStackTrace();            }            num++;        }    }    public static void main(String[] args) {        Main main = new Main();        for (int i = 0; i < 20; i++) {            new Thread -> {                main.test();            }).start();        }        try {            TimeUnit.SECONDS.sleep;            System.out.println;        } catch (InterruptedException e) {            e.printStackTrace();        }    }}

只要在test方法上加一个synchronized关键字,就OK了。

使用

  • 参考上面的三种表现形式

采用synchronized修饰符实现的同步机制叫做互斥锁机制,它所获得的锁叫做互斥锁。每个对象都有一个monitor(锁标记),当线程拥有这个锁标记时才能访问这个资源,没有锁标记便进入锁池。任何一个对象系统都会为其创建一个互斥锁,这个锁是为了分配给线程的,防止打断原子操作。每个对象的锁只能分配给一个线程,因此叫做互斥锁。

1.2 synchronized拥有锁重入

  • 关键字synchronized拥有锁重入的功能,也就是在使用synchronized时,当一个线程得到一个对象锁后,再次请求此对象锁时是可以再次得到该对象的锁的。这也证明在一个synchronized方法/块的内部调用本类的其他synchronized方法/块时,是永远可以得到锁的
  • 可重入锁”的概念是:自己可以再次获取自己的内部锁。比如有1条线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。
    可重入锁也支持在父子类继承的环境中
  • 当存在父子类继承关系时,子类是完全可以通过“可重入锁”调用父类的同步方法的。

Synchronized与原子性

为什么会出现这样的问题呢,可能就有一小部分人不知道其中的原因了。

这和Java的内存模型有关系:在Java的内存模型中,保证并发安全的三大特性是
原子性,可见性,有序性。导致这问题出现的原因 便是 num++
不是原子性操作,它至少有三个操作:
1.把i读取出来
2.做自增计算
3.把值写回i

让我们设想有这样的一个场景:

当num=5

  1. A线程执行到num++这一步,读到了num的值为5(因为还没进行自增操作)。

  2. B线程也执行到了num++这一步,读到了num的值还是为5(因为A线程中的num还没有来得及进行自增操作)。

  3. A线程中的num终于进行了自增操作,num为6。

  4. B线程的num也进行了自增操作,num也为6。

可能光用文字描述,还是有点懵,所以我画了一张图来帮助大家理解:

图片 1

结合文字和图片,应该就可以理解了。

可以看出来,虽然执行了两次自增操作,但是实际的效果只是自增了一次。

所以在第一段代码中,运行的结果并不是1000,而是比1000小的数字。

对于在多线程环境中,出现奇怪的结果或者情况,我们也称为“线程不安全”。

而第二段代码,就是通过Synchronized关键字,把test方法串行化执行了,也就是
A线程执行完test方法,B线程才可以执行test方法。两个线程是互斥的。这样就保证了线程的安全性,最后的结果就是1000。如果从Java内存模型的角度来说,就是保证了操作的“原子性”。

几点说明:

1.3 出现异常,锁自动释放

  • 当一个线程执行的代码出现异常时,其所持有的锁会自动释放。

Synchronized几种使用方法

上面的例子是Synchronized关键字的使用方式之一,此时,synchronized标记的是类的实例方法,锁对象是类的实例对象。当然还有其他使用方式:

 private static synchronized void test() {        for (int i = 0; i < 10; i++) {            try {                TimeUnit.MILLISECONDS.sleep;            } catch (InterruptedException e) {                e.printStackTrace();            }            System.out.println;        }    }

此时,synchronized标记的是类的静态方法,锁对象是类。

以上两种,是直接标记在方法上。

还可以包裹代码块:

    private void test() {        synchronized (Main.class) {            for (int i = 0; i < 10; i++) {                try {                    TimeUnit.MILLISECONDS.sleep;                } catch (InterruptedException e) {                    e.printStackTrace();                }                System.out.println;            }        }    }

此时锁的对象是 类。

    private void test() {        synchronized  {            for (int i = 0; i < 10; i++) {                try {                    TimeUnit.MILLISECONDS.sleep;                } catch (InterruptedException e) {                    e.printStackTrace();                }                System.out.println;            }        }    }

此时锁的对象是类的实例对象。

    private Object object = new Object();    private void test() {        synchronized  {            for (int i = 0; i < 10; i++) {                try {                    TimeUnit.MILLISECONDS.sleep;                } catch (InterruptedException e) {                    e.printStackTrace();                }                System.out.println;            }        }    }

此时,锁对象是Object的对象。

  • 对象锁钥匙只能有一把才能互斥,才能保证共享变量的唯一性
  • 在静态方法上的锁,和实例方法上的锁,默认不是同样的,如果要同步,则需要制定两把锁一样。
  • 关于同一个类的方法上的锁,来自于调用该方法的对象,如果调用该方法的对象是相同的,那么锁必然相同,否则就不相同。比如
    new A().x() 和 new
    A().x(),对象不同,锁不同,如果A是单例的,就能互斥。
  • 静态方法加锁,能和所有其他静态方法加锁的进行互斥
  • 静态方法加锁,和xx.class锁效果一样,直接属于类的

1.4 同步不具有继承性

  • 同步不能继承,所以还得在子类的方法中添加synchronized关键字

JConsole探究Synchronized关键字

我们需要用到JDK自带的一个工具:JConsole,它位于JDK的bin目录下。

为了让观察更加方便,我们需要给线程起一个名字,每个线程内sleep的时间稍微长一点:

public class Main {    private synchronized void test() {        try {            TimeUnit.SECONDS.sleep;        } catch (InterruptedException e) {            e.printStackTrace();        }    }    public static void main(String[] args) {        Main main = new Main();        for (int i = 0; i < 5; i++) {            new Thread -> {                main.test();            }, "Hello,Thread " + i).start();        }    }}

我们先启动项目,然后打开JConsole,找到你项目的进程,就可以连接上去了。

可以看到,5个线程已经显示在JConsole里面了:

图片 2

点击某个线程,可以看到关于线程的一些信息:

图片 3

图片 4

其中四个线程都处于BLOCKED,只有一个处于TIME_WAITING,说明只有一个线程获得了锁,并在TIME_WAITING,其余的线程都没有获得锁,没有进入到方法,说明了Synchronized的互斥性。关于线程的状态,这篇不会深入,以后可能会介绍这方面的知识。

因为我是一边写博客,一边执行各种操作的,所以速度上有些跟不上,导致截图和描述不同,大家可以自己去试试。

同步代码块

把代码段声明为synchronized,通过这种方法可以保证并发线程在任一时刻只有一个线程可以进入修改共享资源的代码区。

2 synchronized同步代码块

  • 当两个并发线程访问同一个对象object中的synchronized(this)同步代码块时,一段时间内只能有一个线程被执行,另一个线程必须等待当前线程执行完这个代码块以后才能执行该代码块。

  • 当一个线程访问object的一个synchronized同步代码块时,另一个线程仍然可以访问该object对象中的非synchronized(this)同步代码块。

  • 在使用同步synchronized(this)代码块时需要注意的是,当一个线程访问object的一个synchronized(this)同步代码块时,其他线程对同一个object中所有其他synchronized(this)同步代码块的访问将被阻塞,这说明synchronized使用的“对象监视器”是一个

  • synchronized方法一样,synchronized(this)代码块也是锁定当前对象的。

  • 多个线程调用同一个对象中的不同名称的synchronized同步方法或synchronized(this)同步代码块时,调用的效果就是按顺序执行,也就是同步的,阻塞的。

javap探究Synchronized关键字

为了把问题简单化,让大家看的清楚,我只保留synchronized相关的代码:

public class Main {    public static void main(String[] args) {        synchronized (Main.class) {        }    }}

编译后,用javap命令查看字节码文件:

javap -v Main.class

图片 5

用红圈圈出来的就是添加synchronized后带来的命令了。执行同步代码块,先是调用monitorenter命令,执行完毕后,再调用monitorexit命令,为什么会有两个monitorexit呢,一个是正常执行办法后的monitorexit,一个是发生异常后的monitorexit。

synchronized标记方法会是什么情况呢?

public class Main {    public synchronized void Hello(){        System.out.println;    }    public static void main(String[] args) {    }}

图片 6

同步方法

使用关键字synchronized修饰的方法。
synchronized方法控制对类的成员变量的访问

  • 每个类实例对应一把锁。
  • 每个synchronized方法都必须获得调用该方法的类实例的锁方能执行,否则所属线程阻塞。
  • 方法一旦执行,就独占该锁,直到从该方法返回时才将锁释放,此后被阻塞的线程方能获得该锁,重新进入可执行状态。
  • 所有声明为synchronized的成员函数中至多只有一个处于可执行状态,从而有效避免了类成员变量的访问冲突。

2.2 对任意对象监视

  • 使用synchronized(this)格式来同步代码块,其实Java还支持对“任意对象”作为“对象监视器”来实现同步的功能。这个“任意对象”大多数是实例变量及方法的参数使用格式为synchronized(非this对象).
    作用只有1种:
  • synchronized(非this对象x)同步代码块。
  • 1)在多个线程持有“对象监视器”为同一个对象的前提下,同一时间只有一个线程可以执行synchronized(非this对象x)同步代码块中的代码。
  • 2)当持有“对象监视器”为同一个对象的前提下,同一时间只有一个线程可以执行synchronized(非this对象x)同步代码块中的代码。
  • 如果在一个类中有很多个synchronized方法,这时虽然能实现同步,但会受到阻塞,所以影响运行效率;> *
    但如果使用同步代码块锁非this对象,则synchronized(非this)代码块中的程序与同步方法是异步的,不与其他锁this同步方法争抢this锁,则可大大提高运行效率。
  • 使用“synchronized(非this对象x)同步代码块”格式进行同步操作时,对象监视器必须是同一个对象。如果不是同一个对象监视器,运行的结果就是异步调用了,就会交由于对象监视器不同,所以运行结果就是异步的

  • synchronized(非this对象x)”格式的写法是将x对象本身作为“对象监视器”。
    这样就可以得出以下3个结论:

  • 1)当多个线程同时执行synchronized(x){}同步代码块时呈同步效果。
  • 2)当其他线程执行x对象中synchronized同步方法时呈同步效果。
  • 3)当其他线程执行x对象方法里面的synchronized(this)代码块时也呈现同步效果。
    但需要注意:如果其他线程调用不加synchronized关键字的方法时,还是异步调用。

锁与Monitor

JVM为每个对象都分配了一个monitor,syncrhoized就是利用monitor来实现加锁,解锁。同一时刻,只有一个线程可以获得monitor,并且执行被包裹的代码块或者方法,其他线程只能等待monitor释放,整个过程是互斥的。monitor拥有一个计数器,当线程获取monitor后,计数器便会+1,释放monitor后,计数器便会-1。那么为什么会是+1,-1
的操作,而不是“获得monitor,计数器=1,释放monitor后,计数器=0”呢?这就涉及到
锁的重入性了。我们还是通过一段简单的代码来看:

public static void main(String[] args) {        synchronized (Main.class){            System.out.println("第一个synchronized");            synchronized (Main.class){                System.out.println("第二个synchronized");            }        }    }

结果:
图片 7

主线程获取了类锁,打印出
“第一个synchronized”,紧接着主线程又获取了类锁,打印出“第二个synchronized”。

问题来了,第一个类锁明明还没有释放,下面又获取了这个类锁。如果没有“锁的重入性”,这里应该只会打印出
“第一个synchronized”,然后程序就死锁了,因为它会一直等待释放第一个类锁,但是却永远等不到那一刻。

这也就是解释了为什么会是“当线程获取monitor后,计数器便会+1,释放monitor后,计数器便会-1“这样的设计。只有当计数器=0,才代表monitor已经被释放。第二个线程才能再次获取monitor。

当然,锁的重入性是针对于同一个线程来说。

静态同步synchronized方法与synchronized(class)代码块

  • 关键字synchronized还可以应用在static静态方法上,如果这样写,那是对当前的*.java文件对应的Class类进行持锁。
  • synchronized关键字加到static静态方法上是给Class类上锁,而synchronized关键字加到非static静态方法上是给对象上锁。
  • 异步的原因是持有不同的锁,一个是对象锁,另外一个是Class锁,而Class锁可以对类的所有对象实例起作用
  • 同步synchronized(class)代码块的作用其实和synchronized
    static方法的作用一样。

将synchronized(string)同步块与String联合使用时,要注意常量池以带来的一些例外。

Synchronized与有序性,可见性

在上一篇中,我们简单的介绍了指令重排,知道了三大特性之一的有序性,但是介绍的太简单。这一次,我们把上一次的内容补充下。

其实,指令重排分为两种:

  1. 编译器重排
  2. 运行时CPU指令排序

为什么编译器和CPU会做“指令重排”这个“吃力不讨好”的事情呢?当然是为了效率。

指令重排会遵守两个规则:即 self-if-serial 和 happens-before。

我们来举一个例子:

int a=1;//1int b=5;//2int c=a+b;//3

这结果显而易见:c=6。

但是这段代码真正交给CPU去执行是按照什么顺序呢,大部分人会认为
”从上到下”。是的,从大家开始学编程第一天就被灌输了这个思想,但是这仅仅是一个幻觉,真正交给CPU执行,可能是
先执行第二行,然后再执行第一行,最后是第三行。因为第一行和第二行,哪一行先运行,并不影响最终的结果,但是第三行的执行顺序就不能改变了,因为数据存在依懒性。如果改变了第三行的执行顺序,那不乱套了。

编译器,CPU会在不影响单线程程序最终执行的结果的情况下进行“指令重排”。

这就是“ self-if-serial”规则。

这个规则就给程序员造给一种假象,在单线程中,代码都是从上到下执行的,殊不知,编译器和CPU其实在背后偷偷的做了很多事情,而做这些事情的目的只有一个“提高执行的速度”。

在单线程中,我们可能并不需要关心指令重排,因为无论背后进行了多么翻天覆地的“指令重排”都不会影响到最终的执行结果,但是self-if-serial是针对于单线程的,对于多线程,会有第二个规则:happens-before

happens-before用来表述两个操作之间的关系。如果A happens-before
B,也就代表A发生在B之前。

由于两个操作可能处于不同的线程,happens-before规定,如果一个线程A
happens-before另外一个线程B,那么A对B可见,正是由于这个规定,我们说Synchronized保证了线程的“可见性”。Synchronized具体是怎么做的呢?当我们获得锁的时候,执行同步代码,线程会被强制从主内存中读取数据,先把主内存的数据复制到本地内存,然后在本地内存进行修改,在释放锁的时候,会把数据写回主内存。

而Synchronized的同步特性,显而易见的保证了“有序性”。

总结一下,Synchronized既可以保证“原子性”,又可以保证“可见性”,还可以保证“有序性”

Synchronized与单例模式

Synchronized最经典的应用之一就是 懒汉式单例模式 了,如下:

public class Main {    private static Main main;    private Main() {    }    public static Main getInstance() {        if (main == null) {            synchronized (Main.class) {                if (main == null) {                    main = new Main();                }            }        }        return main;    }}

相信这代码,大家已经熟悉的不能再熟悉了,但是在极端情况下,可能会产生意想不到的情况,这个时候,Synchronized的好基友Volatile就出现了,这是我们下一节中要讲的内容。

Synchronized可以说是每次面试必定会出现的问题,平时在多线程开发的时候也会用到,但是真正要理解透彻,还是有不小难度。虽说Synchronized的互斥性,很影响性能,Java也提供了不少更好用的的并发工具,但是Synchronized是并发开发的基础,所以值得花点时间去好好研究。

好了,本节的内容到这里结束了,文章已经相当长了,但是还有一大块东西没有讲:JDK1.6对Synchronized进行的优化,有机会,会再抽出一节的内容来讲讲这个。

发表评论

电子邮件地址不会被公开。 必填项已用*标注

网站地图xml地图