【Java多线程-6】synchronized同步锁

Java基础 专栏收录该内容
15 篇文章 140 订阅


前文描述了Java多线程编程,多线程的方式提高了系统资源利用和程序效率,但多个线程同时处理共享的数据时,就将面临线程安全的问题。

例如,下面模拟这样一个场景:一个售票处有3个售票员,出售20张票。

public class SellTickets {
    public static void main(String[] args) {
        TicketSeller seller = new TicketSeller();

        Thread t1 = new Thread(seller, "窗口1");
        Thread t2 = new Thread(seller, "窗口2");
        Thread t3 = new Thread(seller, "窗口3");

        t1.start();
        t2.start();
        t3.start();
    }
}

class TicketSeller extends Thread {
    private static int tickets = 20;

    @Override
    public void run() {
        while (true) {
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {

            }
            if (tickets > 0) {
                System.out.println(Thread.currentThread().getName() + "正在出售第" + (tickets--) + "张票");
            }
        }
    }
}

运行后,发现会出现多个售票员出售同一张票的现象:
在这里插入图片描述
为了解决线程安全的问题,Java提供了多种同步锁。

1 synchronized 原理概述

1.1 操作系统层面

synchronized的底层是使用操作系统的mutex lock实现的。下面先了解一些相关的概念。

  • 内存可见性:同步块的可见性是由以下两个规则获得的:
    1. 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值。
    2. 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store和write操作)。
  • 操作原子性:持有同一个锁的两个同步块只能串行地进入

锁的内存语义:

  • 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。
  • 当线程获取锁时,JMM会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。

锁释放和锁获取的内存语义:

  • 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
  • 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
  • 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息

在这里插入图片描述

Mutex Lock

监视器锁(Monitor)本质是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。每个对象都对应于一个可称为" 互斥锁" 的标记,这个标记用来保证在任一时刻,只能有一个线程访问该对象。

互斥锁:用于保护临界区,确保同一时间只有一个线程访问数据。对共享资源的访问,先对互斥量进行加锁,如果互斥量已经上锁,调用线程会阻塞,直到互斥量被解锁。在完成了对共享资源的访问后,要对互斥量进行解锁。

mutex的工作方式:
在这里插入图片描述

  1. 申请mutex,如果成功,则持有该mutex,如果失败,则进行spin自旋. spin的过程就是在线等待mutex, 不断发起mutex gets, 直到获得mutex或者达到spin_count限制为止
  2. 依据工作模式的不同选择yiled还是sleep
  3. 若达到sleep限制或者被主动唤醒或者完成yield, 则重复1-2步,直到获得为止

由于Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一条线程,都需要操作系统来帮忙完成,这就需要从用户态转换到内核态中,因此状态转换需要耗费很多的处理器时间。所以synchronized是Java语言中的一个重量级操作。在JDK1.6中,虚拟机进行了一些优化,譬如在通知操作系统阻塞线程之前加入一段自旋等待过程,避免频繁地切入到核心态中。

synchronized与java.util.concurrent包中的ReentrantLock相比,由于JDK1.6中加入了针对锁的优化措施(见后面),使得synchronized与ReentrantLock的性能基本持平。ReentrantLock只是提供了synchronized更丰富的功能,而不一定有更优的性能,所以在synchronized能实现需求的情况下,优先考虑使用synchronized来进行同步。

1.2 JVM层面

synchronized用的锁是存在Java对象头里的,那么什么是Java对象头呢?

Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。其中Klass Point是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键,所以下面将重点阐述。

长度内容说明
32/64bitMark Word存储对象的hashCode 或锁信息
32/64bitClass Metadata Address存储对象类型数据的指针
32/64bitArray length数组的长度(如果当前对象是数组)

Mark Word

Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit),但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。

对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化,变化状态如下(32位虚拟机):
在这里插入图片描述

Monitor

什么是Monitor?我们可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。

与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。

Monitor 是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

其结构如下:
在这里插入图片描述

  • Owner:初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL。
  • EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程。
  • RcThis:表示blocked或waiting在该monitor record上的所有线程的个数。
  • Nest:用来实现重入锁的计数。HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)。
  • Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值,0表示没有需要唤醒的线程,1表示要唤醒一个继任线程来竞争锁。

2 synchronized 使用

synchronized是Java中的关键字,是一种同步锁,它修饰的对象有以下几种:

序号类别作用范围作用对象
1同步代码块被synchronized修饰的代码块调用这个代码块的单个对象
2同步方法被synchronized修饰的方法调用该方法的单个对象
3同步静态方法被synchronized修饰的静态方法静态方法所属类的所有对象
4同步类被synchronized修饰的代码块该类的所有对象

2.1 同步代码块

同步代码块就是将需要的同步的代码使用同步锁包裹起来,这样能减少阻塞,提高程序效率。

同步代码块格式如下:

    synchronized(对象){
    	同步代码;
    }

同样对于文章开头卖票的例子,进行线程安全改造,代码如下:

public class SellTickets {
    public static void main(String[] args) {
        TicketSeller seller = new TicketSeller();

        Thread t1 = new Thread(seller, "窗口1");
        Thread t2 = new Thread(seller, "窗口2");
        Thread t3 = new Thread(seller, "窗口3");

        t1.start();
        t2.start();
        t3.start();
    }
}

class TicketSeller implements Runnable {
    private static int tickets = 100;

    @Override
    public void run() {
        while (true) {
            synchronized (this) {
                try {
                    Thread.sleep(10);
                    if (tickets > 0) {
                        System.out.println(Thread.currentThread().getName() + "正在出售第" + (tickets--) + "张票");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

同步代码块的关键在于锁对象,多个线程必须持有同一把锁,才会实现互斥性。

将上面代码中的 synchronized (this) 改为 synchronized (new Objcet()) 的话,线程安全将得不到保证,因为两个线程的持锁对象不再是同一个。

又比如下面这个例子:

public class SyncTest implements Runnable {
    // 共享资源变量
    int count = 0;

    @Override
    public void run() {
        synchronized (this) {
            for (int i = 0; i < 5; i++) {
                System.out.println(Thread.currentThread().getName() + ":" + count++);
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) {
//        test1();
        test2();
    }

    public static void test1() {
        SyncTest syncTest1 = new SyncTest();
        Thread thread1 = new Thread(syncTest1, "thread-1");
        Thread thread2 = new Thread(syncTest1, "thread-2");
        thread1.start();
        thread2.start();
    }

    public static void test2() {
        SyncTest syncTest1 = new SyncTest();
        SyncTest syncTest2 = new SyncTest();

        Thread thread1 = new Thread(syncTest1, "thread-1");
        Thread thread2 = new Thread(syncTest2, "thread-2");
        thread1.start();
        thread2.start();
    }
}

从输出结果可以看出,test2() 方法无法实现线程安全,原因在于我们指定锁为this,指的就是调用这个方法的实例对象,然而 test2() 实例化了两个不同的实例对象 syncTest1,syncTest2,所以会有两个锁,thread1与thread2分别进入自己传入的对象锁的线程执行 run() 方法,造成线程不安全。

如果要使用这个经济实惠的锁并保证线程安全,那就不能创建出多个不同实例对象。如果非要想 new 两个不同对象出来,又想保证线程同步的话,那么 synchronized 后面的括号中可以填入SyncTest.class,表示这个类对象作为锁,自然就能保证线程同步了。

synchronized(xxxx.class){
  //todo
}

一个线程访问一个对象的synchronized代码块时,别的线程可以访问该对象的非synchronized代码块而不受阻塞。

例如下面的例子:

public class SyncTest {
    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread thread1 = new Thread(counter, "线程-1");
        Thread thread2 = new Thread(counter, "线程-2");
        thread1.start();
        thread2.start();
    }
}

class Counter implements Runnable {
    private int count = 0;

    public void countAdd() {
        synchronized (this) {
            for (int i = 0; i < 5; i++) {
                try {
                    System.out.println(Thread.currentThread().getName() + " 同步计数:" + (count++));
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public void printCount() {
        for (int i = 0; i < 5; i++) {
            try {
                System.out.println(Thread.currentThread().getName() + " 非同步输出:" + count);
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public void run() {
        String threadName = Thread.currentThread().getName();
        if (threadName.equals("线程-1")) {
            countAdd();
        } else if (threadName.equals("线程-2")) {
            printCount();
        }
    }
}

我们也可以用synchronized 给对象加锁。这时,当一个线程访问该对象时,其他试图访问此对象的线程将会阻塞,直到该线程访问对象结束。也就是说谁拿到那个锁谁就可以运行它所控制的那段代码,,例如下例:

public class SyncTest {
    public static void main(String args[]) {
        Account account = new Account("zhang san", 10000.0f);
        AccountOperator accountOperator = new AccountOperator(account);

        final int THREAD_NUM = 5;
        Thread threads[] = new Thread[THREAD_NUM];
        for (int i = 0; i < THREAD_NUM; i++) {
            threads[i] = new Thread(accountOperator, "Thread-" + i);
            threads[i].start();
        }
    }
}

class Account {
    String name;
    double amount;

    public Account(String name, double amount) {
        this.name = name;
        this.amount = amount;
    }

    //存钱
    public void deposit(double amt) {
        amount += amt;
        try {
            Thread.sleep(0);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    //取钱
    public void withdraw(double amt) {
        amount -= amt;
        try {
            Thread.sleep(0);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public double getBalance() {
        return amount;
    }
}

class AccountOperator implements Runnable {
    private Account account;

    public AccountOperator(Account account) {
        this.account = account;
    }

    public void run() {
        synchronized (account) {
            String name = Thread.currentThread().getName();
            account.deposit(500);
            System.out.println(name + "存入500,最新余额:" + account.getBalance());
            account.withdraw(400);
            System.out.println(name + "取出400,最新余额:" + account.getBalance());
            System.out.println(name + "最终余额:" + account.getBalance());
        }
    }
}

同步锁可以使用任意对象作为锁,当没有明确的对象作为锁,只是想让一段代码同步时,可以创建一个特殊的对象来充当锁:

class Test implements Runnable {
   private byte[] lock = new byte[0];  // 特殊的instance变量
   public void method() {
      synchronized(lock) {
         // todo 同步代码块
      }
   }
 
   public void run() {
 
   }
}

2.2 同步方法

Synchronized修饰一个方法很简单,就是在方法的前面加synchronized,synchronized修饰方法和修饰一个代码块类似,只是作用范围不一样,修饰代码块是大括号括起来的范围,而修饰方法范围是整个函数。

public synchronized void method(){
   // todo
}

下面用同步函数的方式解决售票场景的线程安全问题,代码如下:

public class SellTickets {
    public static void main(String[] args) {
        TicketSeller seller = new TicketSeller();

        Thread t1 = new Thread(seller, "窗口1");
        Thread t2 = new Thread(seller, "窗口2");
        Thread t3 = new Thread(seller, "窗口3");

        t1.start();
        t2.start();
        t3.start();
    }
}

class TicketSeller implements Runnable {
    private static int tickets = 100;

    @Override
    public void run() {
        while (true) {
            sellTickets();
        }
    }

    public synchronized void sellTickets() {
        try {
            Thread.sleep(10);
            if (tickets > 0) {
                System.out.println(Thread.currentThread().getName() + "正在出售第" + (tickets--) + "张票");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

同步方法有以下特征:

  1. synchronized关键字不能继承。 虽然可以使用synchronized来定义方法,但synchronized并不属于方法定义的一部分,因此,synchronized关键字不能被继承。如果在父类中的某个方法使用了synchronized关键字,而在子类中覆盖了这个方法,在子类中的这个方法默认情况下并不是同步的,而必须显式地在子类的这个方法中加上synchronized关键字才可以。当然,还可以在子类方法中调用父类中相应的方法,这样虽然子类中的方法不是同步的,但子类调用了父类的同步方法,因此,子类的方法也就相当于同步了。
  2. 在定义接口方法时不能使用synchronized关键字。
  3. 构造方法不能使用synchronized关键字,但可以使用synchronized代码块来进行同步。

2.3 同步静态方法

Synchronized也可修饰一个静态方法,静态方法是不属于当前实例的,而是属性类的,那么这个锁就是类的class对象锁。同步静态方法可以解决同步方法和同步代码块中的一个问题:new 两个对象的话,等于有两把锁,无法保证线程安全。

public class SyncTest {
    public static void main(String args[]) {
        SyncThread syncThread1 = new SyncThread();
        SyncThread syncThread2 = new SyncThread();
        Thread thread1 = new Thread(syncThread1, "Thread-1");
        Thread thread2 = new Thread(syncThread2, "Thread-2");
        thread1.start();
        thread2.start();
    }
}

class SyncThread implements Runnable {
    private static int count = 0;

    public synchronized static void method() {
        for (int i = 0; i < 5; i++) {
            try {
                System.out.println(Thread.currentThread().getName() + ":" + (count++));
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public synchronized void run() {
        method();
    }
}

syncThread1 和 syncThread2 是 SyncThread 的两个对象,但在 thread1 和 thread2 并发执行时却保持了线程同步。这是因为run中调用了静态方法method,而静态方法是属于类的,所以syncThread1和syncThread2相当于用了同一把锁。

2.4 同步类

Synchronized还可作用于一个类,用法如下:

class ClassName {
   public void method() {
      synchronized(ClassName.class) {
         // todo
      }
   }
}

同步类与同步静态方法有相同的效果,该类的所有对象都是持有同一把锁:

public class SyncTest {
    public static void main(String args[]) {
        SyncThread syncThread1 = new SyncThread();
        SyncThread syncThread2 = new SyncThread();
        Thread thread1 = new Thread(syncThread1, "Thread-1");
        Thread thread2 = new Thread(syncThread2, "Thread-2");
        thread1.start();
        thread2.start();
    }
}

class SyncThread implements Runnable {
    private static int count = 0;

    public void method() {
        synchronized (SyncThread.class) {
            for (int i = 0; i < 5; i++) {
                try {
                    System.out.println(Thread.currentThread().getName() + ":" + (count++));
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public synchronized void run() {
        method();
    }
}
  • 4
    点赞
  • 3
    评论
  • 21
    收藏
  • 打赏
    打赏
  • 扫一扫,分享海报

评论 3 您还未登录,请先 登录 后发表或查看评论
©️2022 CSDN 皮肤主题:深蓝海洋 设计师:CSDN官方博客 返回首页

打赏作者

云深i不知处

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值