Java中Synchronized用法详解

Synchronized 作用介绍

Synchronized 是 Java 中的关键字,是一种同步锁。它能保证在同一时刻最多只有一个线程执行该段代码,从而达到保证并发安全的效果。

为了能更好的理解 Synchronized 的作用,我们先来看个并发不安全的例子。

例子很简单,有两个英雄对同一个 boss 发起攻击,boss 血量为10000点,每受到一次攻击就会减少1点血,我们让这两个英雄都对 boss 发起5000次攻击。

定义一个Boss类

@Data
@AllArgsConstructor
public class Boss {

    private String bossName;
    private int bossHp;

    /**
     * 受到攻击
     *
     * 每次受到攻击固定会掉1点血
     */
    public void beAttacked(){
        this.bossHp --;
    }
}

创建一个10000点血的 boss 对象,并模拟两个英雄(线程)同时对这个 boss 对象发起5000次攻击。

public static void main(String[] args) {

	// 创建一个有10000点血的 boss 对象
	Boss boss = new Boss("大魔王",10000);

	// 创建两个英雄,分属不同线程,每个英雄都会对 boss 发起 5000 次攻击
	Thread hero1 = new Thread(() -> {for(int i = 0; i < 5000; i++){ boss.beAttacked(); } });
	Thread hero2 = new Thread(() -> {for(int i = 0; i < 5000; i++){ boss.beAttacked(); } });

	// 两个英雄同时发起攻击
	hero1.start();
	hero2.start();

	// 让主线程等待两个子线程运行结束后,再继续执行后边逻辑
	try {
		hero1.join();
		hero2.join();
	} catch (InterruptedException e) {
		e.printStackTrace();
	}

	// 输出 boss 剩余血量
	System.out.println(String.format("[%s] 剩余血量为:%d", boss.getBossName(), boss.getBossHp()));
}

执行一下main方法,得到如下输出:

[大魔王] 剩余血量为:1795

咦,boss 有10000点血,两个英雄每个对 boss 发起5000次攻击,每次攻击减少1点血,那通过计算 10000 - 2 * 5000 * 1 = 0,最后 boss 应该剩余0点血才对呀,为什么还会剩余这么多血量呢?

其实原因是这样的,在 boss 减血的方法中,this.bossHp -- 这个操作,实际上需要三个动作才能完成:

  1. 从主内存中读取 this.bossHp 的值到自己的本地工作内存;
  2. 在本地工作内存中将 this.bossHp 值减1;
  3. 将 this.bossHp 的新值写回到主内存中。

类似于下边这个模型
Java中Synchronized用法详解_第1张图片
当两个线程对 boss 发起攻击时,如出现下边这样的时序逻辑,则就可能会出现少减血的情况。(假设当前 bossHp 值为10)

主内存 hero1线程本地内存 hero2线程本地内存
bossHp = 10 读取 bossHp 值到本地内存
(bossHp = 10)
bossHp = 10 将 bossHp 的值减1
(bossHp = 9)
读取 bossHp 值到本地内存
(bossHp = 10)
bossHp = 9
(bossHp 的值被 hero1 线程改为9)
将 bossHp 的新值 9 写回主内存 将 bossHp 的值减1
(bossHp = 9)
bossHp = 9
(bossHp 的值被 hero2 线程改为9)
将 bossHp 的新值 9 写回主内存

从上边的时序逻辑中可以看到,两次都将 bossHP 值修改成了9,这样就导致了少减血的情况。而防止出现这种并发问题的一个解决办法,就是使用 Synchronized 。

下边,我们就先加上 Synchronized 关键字再看下效果。

@Data
@AllArgsConstructor
public class Boss {

    private String bossName;
    private int bossHp;

    /**
     * 受到攻击
     *
     * 每次受到攻击固定会掉1点血
     */
    public synchronized void beAttacked(){
        this.bossHp --;
    }
}

只需要在 beAttacked() 方法前边加上 synchronized ,然后再来运行一下之前的 main 方法,这次我们就可以得到正确的输出值 0 了。

Synchronized 加锁原理简析

使用 synchronized 同步锁的方式,有的同学认为是对后边紧跟的代码块内容加上了锁,其实这样理解是不对的, synchronized 实际上是将一个Java对象当做了锁,当某个线程获取到锁时,会将线程信息记录到这个对象的对象头中,当占用结束后,再将锁释放,这样其它线程就可以获取到锁了。

还是以上边加锁的代码为例

public synchronized void beAttacked(){
	this.bossHp --;
}

当我们创建一个 boss 对象 Boss boss = new Boss("大魔王",10000); ,并且再有 hero1 和 hero2 两个线程调用 beAttacked() 方法时,同步加锁的流程如下:

hero1 线程 hero2 线程
运行到 synchronized 处,检查 boss 对象的对象头,发现没有被其它线程占用,则在 boss 对象头中记录本线程的线程ID等信息
进入方法体,执行方法体逻辑 运行到 synchronized 处,检查 boss 对象的对象头,发现已经被其它线程占用,等待其它线程释放锁
方法执行结束,释放锁
其它线程释放锁,本线程获取到锁,在 boss 对象头中记录本线程的线程ID等信息
进入方法体,执行方法体逻辑
方法执行结束,释放锁

Synchronized 的几种用法

Synchronized 在使用过程中,可以归纳为有两类用法:

  • 对象锁
    • 修饰一个代码块,如:synchronized(对象) {...}
    • 修饰一个成员方法
  • 类锁
    • 修饰一个代码块,如:synchronized(类.class) {...}
    • 修饰一个静态方法

这里的对象锁和类锁有什么本质的区别呢?

在java世界里,一切皆对象。从某种意义上来说,java有两种对象:实例对象和Class对象。每个类的运行时的类型信息就是用Class对象表示的,它包含了与类有关的信息。其实我们的实例对象就通过Class对象来创建的。Class对象是在类加载的时候由Java虚拟机以及通过调用类加载器中的 defineClass 方法自动构造的。

最关键的一点就是,Class对象是单例的,也就说一个类只会有一个对应的Class对象。

我们再来通过代码示例来理解一下。

我们将 Boss 类中 beAttacked 方法去掉 synchronized 关键字,并在方法内加入输出语句。

@Data
@AllArgsConstructor
public class Boss {

    private String bossName;
    private int bossHp;

    /**
     * 受到攻击
     *
     * 每次受到攻击固定会掉1点血
     */
    public void beAttacked(){
        System.out.println(String.format("%s 对 %s 发起了攻击", Thread.currentThread().getName(), this.getBossName()));

        this.bossHp --;

        System.out.println(String.format("%s 对 %s 攻击结束", Thread.currentThread().getName(), this.getBossName()));
    }
}

并将客户端 main 方法中 hero1 和 hero2 线程分别起名为 Hero_1 和 Hero_2。

public static void main(String[] args) {

	// 创建一个有10000点血的 boss 对象
	Boss boss = new Boss("大魔王",10000);

	// 创建两个英雄,分属不同线程,每个英雄都会对 boss 发起 5000 次攻击
	Thread hero1 = new Thread(() -> {for(int i = 0; i < 5000; i++){ boss.beAttacked(); } }, "Hero_1");
	Thread hero2 = new Thread(() -> {for(int i = 0; i < 5000; i++){ boss.beAttacked(); } }, "Hero_2");

	// 两个英雄同时发起攻击
	hero1.start();
	hero2.start();

	// 让主线程等待两个子线程运行结束后,再继续执行后边逻辑
	try {
		hero1.join();
		hero2.join();
	} catch (InterruptedException e) {
		e.printStackTrace();
	}
}

运行一下,从控制台输出中,我们很容易看到如下的这种输出

Hero_1 对 大魔王 发起了攻击
Hero_2 对 大魔王 发起了攻击
Hero_1 对 大魔王 攻击结束
Hero_2 对 大魔王 攻击结束

从输出中我们就可以看出,两个线程可以并行的执行方法体内容。

接下来我们在 beAttacked() 方法中加入 synchronized 同步代码块

public void beAttacked(){
	synchronized (this){
		System.out.println(String.format("%s 对 %s 发起了攻击", Thread.currentThread().getName(), this.getBossName()));

		this.bossHp --;

		System.out.println(String.format("%s 对 %s 攻击结束", Thread.currentThread().getName(), this.getBossName()));
	}
}

再次运行 main 方法后,可以发现同一个线程的两次打印输出之间不会夹杂其它线程的输出,也就是说,一个线程执行完此段代码之前,其它线程无法执行此段代码,如下:

Hero_1 对 大魔王 发起了攻击
Hero_1 对 大魔王 攻击结束
Hero_2 对 大魔王 发起了攻击
Hero_2 对 大魔王 攻击结束

但接下来我们改一下 main 方法中的逻辑,之前的逻辑是 hero1 和 hero2 线程会攻击同一个boss,我们改为 hero1 攻击 boss1 , hero2 攻击 boss2 。

public static void main(String[] args) {

	// 创建一个有10000点血的 boss1 对象
	Boss boss1 = new Boss("大魔王1",10000);
	// 创建一个有10000点血的 boss2 对象
	Boss boss2 = new Boss("大魔王2",10000);

	// 创建两个英雄,分属不同线程,每个英雄都会对 boss 发起 5000 次攻击
	Thread hero1 = new Thread(() -> {for(int i = 0; i < 5000; i++){ boss1.beAttacked(); } }, "Hero_1");
	Thread hero2 = new Thread(() -> {for(int i = 0; i < 5000; i++){ boss2.beAttacked(); } }, "Hero_2");

	// 两个英雄同时发起攻击
	hero1.start();
	hero2.start();

	// 让主线程等待两个子线程运行结束后,再继续执行后边逻辑
	try {
		hero1.join();
		hero2.join();
	} catch (InterruptedException e) {
		e.printStackTrace();
	}
}

再来运行下,从控制台输出中,我们还是可以看到如下的这种输出

Hero_2 对 大魔王2 发起了攻击
Hero_1 对 大魔王1 发起了攻击
Hero_2 对 大魔王2 攻击结束
Hero_1 对 大魔王1 攻击结束

这是为什么呢,我们不是已经在 beAttacked() 方法中加了 synchronized 了么?

其实,这里也比较容易理解,也就是我们前边说的,锁并不是加在代码块上的,而是加在对象上。

这里我们用的是 synchronized (this){...} 这种对象锁形式,this 指的是当前对象,hero1 线程中调用 beAttacked() 方法的是 boss1 对象,也就是锁信息记录在了 boss1 对象的对象头中,而hero2 线程中调用 beAttacked() 方法的是 boss2 对象,锁信息记录在了 boss2 对象的对象头中,它们是两把不同的锁,并不会冲突,也就是不会互斥,所以可以同时执行。

接下来,我们将 synchronized (this){...} 改为 synchronized (类.class){...} 再来试试。

public void beAttacked(){
	synchronized (Boss.class){
		System.out.println(String.format("%s 对 %s 发起了攻击", Thread.currentThread().getName(), this.getBossName()));

		this.bossHp --;

		System.out.println(String.format("%s 对 %s 攻击结束", Thread.currentThread().getName(), this.getBossName()));
	}
}

再次运行 main 方法后,又会发现同一个线程的两次打印输出之间不会夹杂其它线程的输出了,如下:

Hero_1 对 大魔王1 发起了攻击
Hero_1 对 大魔王1 攻击结束
Hero_2 对 大魔王2 发起了攻击
Hero_2 对 大魔王2 攻击结束

这是因为 Boss.class 对象只有一个, hero1 线程和 hero2 线程此时再获取锁时,就会出现互斥的情况了。

明白了上边的逻辑,那么在成员方法或静态方法上加 synchronized 关键字也就好理解了。

成员方法是需要有具体对象才可以调用的,所以效果等同于 synchronized (this){...},而静态方法是属于类的,所以效果等同于 synchronized (类.class){...}

还有,如果同一个对象的两个成员方法上都加有 synchronized 关键字,让一个线程调用这个对象的方法1,另外一个线程调用它的方法2,这种情况也是会出现锁争用的,原理嘛都一样,只要紧抓住锁是加在对象上的就好理解了。

当然了,虽然我们平常的时候一般都是使用上边的几种写法,但其实 synchronized 可以将任意对象指定为锁,如下边代码:

@Data
@AllArgsConstructor
public class Boss {

    private String bossName;
    private int bossHp;

    // 创建一个对象,用于对 beAttacked() 方法体加锁
    private static final Object lock = new Object();

    /**
     * 受到攻击
     *
     * 每次受到攻击固定会掉1点血
     */
    public void beAttacked(){
        synchronized (lock){
            System.out.println(String.format("%s 对 %s 发起了攻击", Thread.currentThread().getName(), this.getBossName()));

            this.bossHp --;

            System.out.println(String.format("%s 对 %s 攻击结束", Thread.currentThread().getName(), this.getBossName()));
        }
    }
}

同样的,也可以将其它类的类.class作为锁,如 synchronized (Object.class){...}synchronized (String.class){...} 等等。(虽然这些是被允许的,但我们在实际开发中,最好不要用无关联意义的对象或类对象来作为锁。)

Synchronized 的几种性质

可重入性

可重入的意思是,如果一个线程获取到锁但还没有释放时,它自己还可以继续获取到此锁。

再来通过个例子来看下,在 Boss 类中加入另外一个同步方法,并在 beAttacked() 方法结束之前调用新方法:

@Data
@AllArgsConstructor
public class Boss {

    private String bossName;
    private int bossHp;

    /**
     * 受到攻击
     *
     * 每次受到攻击固定会掉1点血
     */
    public void beAttacked(){
        synchronized (this){
            System.out.println(String.format("%s 对 %s 发起了攻击", Thread.currentThread().getName(), this.getBossName()));

            this.bossHp --;
            // 触发额外效果
            debuff();
            System.out.println(String.format("%s 对 %s 攻击结束", Thread.currentThread().getName(), this.getBossName()));
        }
    }

    /**
     * 触发额外效果
     */
    private void debuff(){
        synchronized (this) {
            System.out.println(String.format("%s 对 %s 额外造成了减速效果", Thread.currentThread().getName(), this.getBossName()));
        }
    }
}

main 方法中还是让 hero1 和 hero2 同时攻击同一个 boss 对象:

public static void main(String[] args) {

	// 创建一个有10000点血的 boss1 对象
	Boss boss = new Boss("大魔王",10000);

	// 创建两个英雄,分属不同线程,每个英雄都会对 boss 发起 5000 次攻击
	Thread hero1 = new Thread(() -> {for(int i = 0; i < 5000; i++){ boss.beAttacked(); } }, "Hero_1");
	Thread hero2 = new Thread(() -> {for(int i = 0; i < 5000; i++){ boss.beAttacked(); } }, "Hero_2");

	// 两个英雄同时发起攻击
	hero1.start();
	hero2.start();

	// 让主线程等待两个子线程运行结束后,再继续执行后边逻辑
	try {
		hero1.join();
		hero2.join();
	} catch (InterruptedException e) {
		e.printStackTrace();
	}
}

运行后,可以看到如下输出:

Hero_1 对 大魔王 发起了攻击
Hero_1 对 大魔王 额外造成了减速效果
Hero_1 对 大魔王 攻击结束
Hero_2 对 大魔王 发起了攻击
Hero_2 对 大魔王 额外造成了减速效果
Hero_2 对 大魔王 攻击结束

可以看出,同一个线程在获取到对象锁执行 beAttacked() 方法时,还可以再次获取到同一个对象的锁,并执行 debuff() 方法。

这是因为每个对象锁关联一个线程持有者和一个计数器,当一个线程请求成功后,JVM会记下持有锁的线程,并将计数器计为1。此时其他线程请求该锁,则必须等待。而当前持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增。当线程退出一个 synchronized 方法/块时,计数器会递减,如果计数器减为0则释放该锁。

代码抛异常后,会自动释放锁

一般情况下,当线程在执行完synchronized方法/块时,会将锁释放。但如果遇到异常抛出异常时,JVM也会将锁释放。

再来修改下我们的 Boss 类,beAttacked 方法添加一个 bossHp 参数,表示减少的HP数值,并在方法体内判断如果传进来的 bossHp 数值小于或等于0,就会抛出异常。

/**
 * 受到攻击
 * @param lossHp 减少的HP数值
 */
public void beAttacked(int lossHp){
	synchronized (this){
		System.out.println(String.format("%s 对 %s 发起了攻击,lossHp:%d", Thread.currentThread().getName(), this.getBossName(), lossHp));

		// 判断如果减少的HP数值小于或等于0,则抛出异常
		if(lossHp <= 0){
			throw new RuntimeException("减少的HP数值不能小于或等于0");
		}

		this.bossHp -= lossHp;

		System.out.println(String.format("%s 对 %s 攻击结束", Thread.currentThread().getName(), this.getBossName()));
	}
}

在main方法中,我们将 hero1 对 boss 的攻击减血量写为0, hero2 对 boss 的攻击减血量写为1

public static void main(String[] args) {

	// 创建一个有10000点血的 boss1 对象
	Boss boss = new Boss("大魔王",10);

	// 创建两个英雄,分属不同线程,每个英雄都会对 boss 发起 5000 次攻击
	Thread hero1 = new Thread(() -> boss.beAttacked(0), "Hero_1");
	Thread hero2 = new Thread(() -> boss.beAttacked(1), "Hero_2");

	// 两个英雄同时发起攻击
	hero1.start();
	hero2.start();

	// 让主线程等待两个子线程运行结束后,再继续执行后边逻辑
	try {
		hero1.join();
		hero2.join();
	} catch (InterruptedException e) {
		e.printStackTrace();
	}
}

运行后,得到如下输出:

Hero_1 对 大魔王 发起了攻击,lossHp:0
Exception in thread “Hero_1” java.lang.RuntimeException: 减少的HP数值不能小于或等于0
  at Boss.beAttacked(Boss.java:22)
  at Test1.lambda$main 0 ( T e s t 1. j a v a : 14 ) a t T e s t 1 0(Test1.java:14) at Test1 0(Test1.java:14)atTest1$Lambda$1/2074407503.run(Unknown Source)
  at java.lang.Thread.run(Thread.java:744)
Hero_2 对 大魔王 发起了攻击,lossHp:1
Hero_2 对 大魔王 攻击结束

从输出中我们可以看出,hero1 没有运行完代码,但是hero2 也可以获取到了锁,这说明 hero1 抛出异常后,也会释放持有的锁。

Synchronized 的缺陷

Synchronized 使用起来比较方便,但跟其它同步方式(如 Lock 系列)相比,它还存在如下缺点:

  • 试图获取锁时不能设置超时,也不能中断一个正在试图获取锁的线程,也就是要么获取到锁,要么就一直等。
  • 加锁和释放锁的时机单一。
  • 每个锁只有单一的条件,无法对多种条件进行区分。
  • 无法知道是否成功获取到了锁。

总结

Synchronized 是Java多线程高并发的灵魂,要想学好多线程编程,Synchronized 是绕不开的,一篇 Synchronized 基础用法讲解分享给大家,希望能给大家带来帮助!!

你可能感兴趣的:(Java,并发编程,Synchronized用法,Synchronized详解)