线程安全之Synchronized的使用与原理

前文已经讲过Java线程安全问题产生的原因和synchronized的内存语义。这一章将对如何使用synchronized以及通过synchronized加锁的原理进行详细解读。

添加Synchronized锁的三种方式

作用于实例方法

java 复制代码
ublic class Test implements Runnable {
  //静态变量 临界区
  static int count = 0;
  //synchronized修饰实例方法
  public synchronized void add() {
    count++;
 }
  @Override
  public void run() {
    //线程体
    for (int i = 0; i < 1000; i++) {
      add();
   }
 }
  public static void main(String[] args) throws InterruptedException {
    Test8 test8 = new Test8();
    //多个线程操作一个实例对象
    Thread thread1 = new Thread(test8);
    Thread thread2 = new Thread(test8);
    thread1.start();
    thread2.start();
    thread1.join();
    thread2.join();
    System.out.println(count); //2000
 }
}

上述代码模拟了两个线程操作一个共享变量count,分别对count进行自加1000,最终结果是2000。 因为 count++ 不是一个原子操作,分为先读值在加1两步操作,所以在并发执行时,如果不使用synchronized修饰实例方法,那么最终结果很大可能是小于2000的。 但这样加锁并非万无一失,细心观察可以发现,上面代码线程并发操作的是同一个对象。如果有多个实例对象操作一个共享变量时,synchronized锁并不能保证线程的安全,如将上述代码的main方法修改为

java 复制代码
public static void main(String[] args) throws InterruptedException {
  Thread thread1 = new Thread(new Test8());
  Thread thread2 = new Thread(new Test8());
  thread1.start();
  thread2.start();
  thread1.join();
  thread2.join();
  System.out.println(count); //最终结果会小于2000
}

在这种情况下我们该如何保证线程安全呢?

作用于静态方法

java 复制代码
public class Test implements Runnable {
  //静态变量 临界区
  static int count = 0;
  //synchronized修饰静态方法
  public static synchronized void add() {
    count++;
 }
  @Override
  public void run() {
    //线程体
    for (int i = 0; i < 1000; i++) {
      add();
   }
 }
  public static void main(String[] args) throws InterruptedException {
    Thread thread1 = new Thread(new Test8());
    Thread thread2 = new Thread(new Test8());
    thread1.start();
    thread2.start();
    thread1.join();
    thread2.join();
    System.out.println(count); //最终结果还是2000
 }
}

我们将方法定义为静态方法,将锁加在静态方法上。就可以在多线程并发操作同一个类的不同实例对象的时候保证线程安全。 这样做虽然保证了线程安全但还是有一些小小的瑕疵,前文已经说过只有在内存共享区的变量也就是堆中和方法区中的变量需要保证线程安全。也就是说我们只需要保证静态变量count的线程安全就可以了,假如我这个方法很长,为了这一个变量的线程安全就把整个方法锁住,那我们这个程序的运行速度不就变慢了?所以为了减小锁的粒度,我们应该用下面这种方式。

作用于同步代码块

java 复制代码
public class Test8 implements Runnable {
  //全局静态实例
  static Test8 test8=new Test8();
  //静态变量 临界区
  static int count = 0;
  //synchronized修饰实例方法
  public void add() {
    //可以直接锁指定实例synchronized (test8)
    //也可以通过锁定传入的this实例
    synchronized (this) {
      count++;
   }
 }
  @Override
  public void run() {
    //线程体
    for (int i = 0; i < 1000; i++) {
      add();
   }
 }
  public static void main(String[] args) throws InterruptedException {
    Thread thread1 = new Thread(test8);
    Thread thread2 = new Thread(test8);
    thread1.start();
    thread2.start();
    thread1.join();
    thread2.join();
    System.out.println(count); //2000
 }
}

这样加锁就可以将锁加在需要保证线程安全的地方。 三种加锁的方式已经介绍完毕,是时候让我们探究一下加锁的原理了。

什么是锁

前面我们介绍了三种加锁的方式,但不知大家想过没有什么是锁呢?锁,就是对象。

当我们把锁加在实例方法上或者在代码块用synchronized (this)加锁的时候,锁就是当前对象。所以多个线程并发操作同一实例对象的时候他们需要获取的是同一把锁,只能一个一个来。而我再新建一个对象就相当于又多了一把锁可以去抢,得到了锁资源的线程可以操作被锁住的共享变量,如果有多个获得了锁资源的线程去操作同一个共享变量自然会引发线程安全问题。

为了解决这个问题,我们要么让所有的线程都操作同一对象,要么就用一个唯一的对象作为锁。

在Java语言中用static修饰符修饰的变量或者方法,只会在这个类被类加载器加载到JVM里的时候被加载到方法区中。所以当我们需要在多线程并发操作同一个类的多个实例对象的时候保证线程安全除了可以在静态方法上加锁,还可以这样

java 复制代码
public class MyClass { 
    private static final Object lock = new Object(); 
    public void myMethod() { 
    synchronized (lock) { 
    // 加锁的代码块 // ... 
            } 
        } 
    }

通过static和finally关键字修饰可以创建引用地址不会被改变的静态对象。用这个对象作为锁,可以保证被锁住的代码块在多线程并发操作不同实例对象的时候的线程安全。

可能还会有人问,既然是把对象作为锁,那么在静态方法上加锁,是以哪个对象作为锁呢?答案是该类的Class对象。与静态对象一样Class对象在该类的生命周期中唯一。大概可以理解为这样

java 复制代码
    public class MyClass { 
    
    public void myMethod() { 
    synchronized (MyClass.class) { 
    // 加锁的代码块 // ... 
            } 
        } 
    }

对象是如何作为锁的

前面我们已经指明,通过Synchronized加锁的时候,锁就是对象。那么如何通过对象锁住代码块呢?这里我就要介绍一下 锁Synchronized的底层原理------java对象头中的markword字段+操作系统对象monitor

先通过一张图让大家了解一下Markword

可以看到markword的字段会根据锁类型的变化而进行调整,而且还影响着Gc回收。

当锁为轻量级锁或重量级锁时markword主要存储的是一个30位的引用指针。

再看一下Monitor的结构

java 复制代码
//部分属性
ObjectMonitor() {
  _count     = 0;  //锁计数器 进入数
  _owner     = NULL;
  _WaitSet    = NULL; //处于wait状态的线程,会被加入到_WaitSet
  _EntryList   = NULL ; //处于等待锁block状态的线程,会被加入到该列表
}

可以看到,在这个数据结构中记录了当前线程进入锁的次数,锁的拥有者,处于wait状态的线程以及处于等待锁block状态的线程。

线程是如何通过Synchronized加锁的

每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该 对象头的Mark Word 中就被设置指向 Monitor 对象的指针。

具体如图

1.当线程Thread-1要执行临界区代码的时候(也就是被锁住的代码)首先会通过锁对象的markword指向一个monitor对象。

2.当Thread-1线程持有monitor对象后,就会把monitor中的owner变量设置为当前线程Thread-1,同时计数器count+1表示当前对象锁被一个线程获取

3.当另一个线程Thread-2想要执行临界区的代码时,要判断monitor对象的属性Owner是否为null,如果为nullThread-2线程就持有了对象锁可以执行临界区的代码,如果不null,Thread-2线程就会放入monitor的EntryList阻塞队列中,处于阻塞状态Blocked

4.当Thread-0将临界区的代码执行完毕,将释monitor(锁)并将owner变量置为null,同时计算器count-1,并通知EntryList阻塞队列中的线程,唤醒里面的线程

虽然底层原理是一致的但锁加在方法上和代码块上加锁方式还是有区别。

当Synchronized加在方法上时

相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED 标示符。

JVM就是根据该标示符来实现方法的同步的当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor 在方法执行期间,其他任何线程都无法再获得同一个monitor对象

当Synchronized加在代码块上时

它的底层是通过monitorenter、monitorexit指令来实现的

monitorenter: 每个对象都是一个监视器锁(monitor),当对象被占用时就会是锁定状态

monitor进入数(锁计数器)为0时代表无线程占用,当有线程进入时,进入数设置为1,且该线程就是monitor的拥有者owner

当进入线程已经拥有了该monitor,则monitor进入数+1 如果该monitor已被其他线程占用,则该线程进入monitor的阻塞队列中,等待monitor进入数为0

monitorexit:执行monitorexit的线程必须是objectref所对应的monitor持有者

执行monitorexit后,monitor进入数减1,如果进入数减为0,则该线程释放monitor

本来还想记录一下锁升级的过程的,但是细说起来很麻烦还是单独开一章吧。

相关推荐
李慕婉学姐4 小时前
【开题答辩过程】以《基于JAVA的校园即时配送系统的设计与实现》为例,不知道这个选题怎么做的,不知道这个选题怎么开题答辩的可以进来看看
java·开发语言·数据库
奋进的芋圆6 小时前
Java 延时任务实现方案详解(适用于 Spring Boot 3)
java·spring boot·redis·rabbitmq
sxlishaobin6 小时前
设计模式之桥接模式
java·设计模式·桥接模式
model20056 小时前
alibaba linux3 系统盘网站迁移数据盘
java·服务器·前端
荒诞硬汉6 小时前
JavaBean相关补充
java·开发语言
提笔忘字的帝国6 小时前
【教程】macOS 如何完全卸载 Java 开发环境
java·开发语言·macos
2501_941882487 小时前
从灰度发布到流量切分的互联网工程语法控制与多语言实现实践思路随笔分享
java·开发语言
華勳全栈7 小时前
两天开发完成智能体平台
java·spring·go
alonewolf_997 小时前
Spring MVC重点功能底层源码深度解析
java·spring·mvc
沛沛老爹7 小时前
Java泛型擦除:原理、实践与应对策略
java·开发语言·人工智能·企业开发·发展趋势·技术原理