您的位置 首页 java

Java中为什么synchronized能保证线程安全呢?

前言

在我之前的文章《 》中提到,由于JMM(java内存模型)中存在共享变量的内存可见性问题,导致多线程操作同一个共享变量时会产生线程安全问题,也就是产生不确定的结果,那边对共享变量操作的线程安全问题要怎么解决呢?在本篇文章中,我们就一起来看一下,使用synchronized关键字是如何解决线程安全的。

本篇文章主要涉及以下几个内容:

  • 一个非线程安全的例子
  • synchronized的使用
  • synchronized保证内存可见性
  • synchronized保证原子性

一个非线程安全的例子

我们知道,当多个线程对同一个共享变量进行操作时,会有线程安全问题,这是为什么呢呢?下面看一个例子:

 public class Test {
  //定义一个count变量
  private int count = 0;

  //incr对count值进行+1操作
	public void incr() {
   	this.count += 1; 
  }
}  

上面的Test类中,count是一个成员变量,多线程同时操作同一个对象时,对象的成员变量是一个共享变量;其中的incr方法实现了对成员变量count执行+1的操作,并将count+1后的结果写回到count中。

按照正常的逻辑,因为count初始值为0,所以当两个线程执行incr方法时,两个线程都会对count变量执行+1的操作,也就执行了两次+1,因此我们意识中认为两个线程执行完后,count的值应该为2,但实际情况是这样子吗?

在《 》中,我们提到JMM中抽象出了 主内存 线程本地内存 两个概念,主内存是所有线程共享的,而线程本地内存时线程私有的,现在让我们从JMM的角度来看一下,当两个线程执行上述incr方法时会发生什么事情。

线程A执行到incr方法中的this.count+=1这一行代码时,会从主内存中将count的值读入到线程A的本地内存中,此时读入的count的值为0,然后线程A对本地内存中的count变量执行+1操作,得到count的值为1,然后线程A将count=1的值写入到本地内存中,但此时还没有将count=1写入到主内存中;

此时线程B也指向incr方法,同样执行到this.count+=1这一行代码,这时候线程B也会从主内存中将count的值读入到线程B的本地内存中,因为线程A还没有将count=1写回主内存中,所以线程B读取到的count的值也为0,此时线程B执行count+1操作,得到的count值也为1,然后线程A和线程B在将各自的计算结果写回主内存中,最终得到的count的值为1。

下面图解看一下上诉的过程,线程A和线程B从主内存中将count值读取到本地内存中执行计算:

线程从主内存中读取count到本地内存中执行计算

线程A和线程B将计算结果count=1刷新回主内存中:

线程将count结果刷新回主内存

可以看到,由于共享变量存在内存可见性问题导致我们的程序出现了非预期的结果,也就是产生了线程安全问题。

下面让我们看一下怎么使用synchronized关键字解决这个问题。

synchronized的使用

synchronized关键字在使用时,需要与一个锁对象关联,多线程执行时会一起竞争这个锁对象,只有竞争到锁对象的线程才能进入synchronized保护的代码块进行执行。

那么这个锁对象是什么呢?当synchronized使用在不同的地方时,这个锁对象是不同的:

  • 当synchronized修饰在对象的成员方法时,锁对象是当前的实例对象;
  • 当synchronized修饰方法内的代码块时,锁对象是synchronized关键字括号里面指定的对象;
  • 当synchronized修饰静态方法时,锁对象是当前类的class对象;

下面我具体看一下synchronized具体是如何使用的。

使用synchronized修饰对象的成员方法

对于前文中的Test类中的incr方法,使用synchronized修饰一下,如下所示:

 public class Test {
  //定义一个count变量
  private int count = 0;

  //incr对count值进行+1操作
	public void incr() {
   	this.count += 1; 
  }
}  

使用synchronized修饰在方法上,说明当前synchronized的锁对象是当前的实例对象。

使用synchronized代码块

对于前文中的Test类中的incr方法,使用synchronized修饰方法中的代码块,如下所示:

 public class Test {
  //定义一个count变量
  private int count = 0;
	//synchronized锁对象
	private Object lock;

  //incr对count值进行+1操作
	public synchronized void incr() {
    synchronized(this.lock) {
      this.count += 1; 
    }
  }
}  

使用synchronized修饰代码块,需要一个锁对象,在Test类中我们新增了一个lock对象来作为synchronized的锁对象。

因为我们的Test类中没有静态方法,所以关于synchronized关键字修饰静态方法的例子就不列举了,synchronized修饰静态方法与修饰成员方法类似,只不过修饰的是静态方法。

使用synchronized关键字对Test类的incr方法进行调整后,就可以说incr方法就是线程安全的了,也就是不必再担心多线程同时操作会产生线程安全问题。

那为什么使用synchronized修饰后就可以保证多线程执行时线程安全的呢?

synchronized保证内存可见性

在《 》中,我们提到内存可见性问题是由于共享变量不同 线程的本地内存 是不可见的,而 主内存 是所有线程共享的。当不同线程都基于各自本地内存中对共享变量值进行修改时,就会导致共享变量的内存可见性问题,也就是共享变量的值在多个线程中是不一致的。

那如何解决共享变量的内存可见性问题呢?既然内存可见性是由于线程本地内存导致的,那如果禁用线程本地内存是不是就可以保证内存可见性了呢?

假如线程没有本地内存,那么操作的共享变量都需要从主内存中读取,每个线程都是基于主内存中的共享变量进行修改,因此每个线程都可以读取其它线程修改的最新值,因此就不存在共享变量的内存可见性问题。

现在让我们看一下synchronized是怎么做的,是直接禁用线程本地内存吗?

synchonronized其实不是直接禁用线程本地内存,而是通过另外一种方式实现。

当线程进入synchronized关键字修饰的代码块执行时,会首先将当前线程本地内存中的共享变量置为无效,接下来当线程要访问共享变量的值时会直接从主内存中将共享变量的最新值读入到本地内存中,之后线程执行运算时都是基于线程本地内存中的值进行;当线程退出synchronized关键字修饰的方法时会将当前线程本地内存中的共享变量的最新值都写回到主内存中,此时主内存中的值就是最新值;如果另外一个线程同样要执行synchronized关键字修饰的代码块时,会执行同样的操作,也就是首先设置当前线程的本地内存的共享变量为无效,然后从主内存中读取共享变量的最新值,这么一来,线程便可以读取到主内存中共享并的最新值,synchronized就是通过这种方式解决共享变量的内存可见性问题。

与synchronized保证内存可见性相关的还有一条happend-before规则: 一个线程对synchronized的解锁happend-before与后续其它线程对synchronized的加锁。

下面我们对这条原则进行一下解读。

happend-before说的是共享变量的内存可见性问题,线程A执行完synchronized修饰的代码块后会进行解锁操作,解锁时回将当前线程A的本地内存中的共享变量的值写回主内存中;后续线程B要进入synchronized修饰的代码块执行时,需要先执行加锁操作,加锁后线程会将线程B的本地内存设置为无效,强制线程B使用到的共享变量从主内存中读取,因此线程B总是能读取到线程A(或者其它线程)对共享变量修改后的最新值,也就保证了共享变量的内存可见性。

虽然synchronized可以保证共享变量的内存可见性问题,但还是无法解决共享变量被多个线程修改时的线程安全问题,因为可能存在多个线程同时对共享变量进行操作,例如多个线程同时从主内存中读取count的值进行计算,然后同时写回到内存中,此时会导致共享变量的值相互覆盖。所以仅仅解决了共享变量的内存可见性是不够的,还需要保证方法执行的原子性,下面我们来看一下synchronized是如何保证方法执行的原子性。

synchronized保证原子性

原子性意味着不可分割,也就是说多个不可分割的操作同时只能被一个线程执行 。具体到一个方法中,也就是说同一个方法同时只能被一个线程执行,使用synchronized修饰方法,就可以保证方法是原子性的。另外当synchronized修饰的是代码块时,也是可以保证被修饰的代码块是原子性的,也就是同时只能被一个线程执行。

为什么被synchronized修饰的方法或代码块就具备了原子性呢?

java中任何对象都有一个monitor对象与之关联,synchronized关键字需要和一个锁对象配合使用,而锁对象也有一个monitor对象与它关联,而synchronized就算基于进入和退出monitor对象来实现方法同步而代码块同步,进而保证方法或代码块执行的原子性。

当synchronized修饰代码块时,jvm会在编译后字节码中将monitorenter指令插入到代码块的开始位置,monitorexit指令插入到代码块结束处和异常处,jvm会保证每个monitorenter必须有对应的monitorexit与之配对。

当线程执行过程中遇到monitorenter指令时,会去申请获取对应的monitor对象的所有权,当monitor对象被持有时,它就会处于锁定状态;如果其它线程尝试申请处于锁定状态的monitor对象时,因为monitor已经被其它线程获取了,所以当前线程会由于申请失败而进入阻塞状态;当持有monitor对象的线程执行monitorexit指令释放monitor对象的所有权时,就会唤醒处于阻塞状态的线程,然后被唤醒的线程会重新参与到monitor对象所有权的竞争获取中,jvm会保证同时只有一个线程能获取到monitor对象的所有权,也就是保证同时只有一个线程可以进入synchronized修饰的同步代码块中执行。

接下来我们看一个例子:

 public class Test {
  //定义一个count变量
  private int count = 0;
	//synchronized锁对象
	private Object lock;
  //incr对count值进行+1操作
	public synchronized void incr() {
    synchronized(this.lock) {
      this.count += 1; 
    }
  }
}  

上面代码编译为Test.class文件后,使用javap命令可以看到编译后的字节码文件,命令如下:

 javap -c Test.class  

Test类中的incr方法编译后的字节码文件如下所示:

 public synchronized void incr();
    Code:
       0: aload_0
       1: getfield      #3                  // Field lock:Ljava/lang/Object;
       4: dup
       5: astore_1
       6: monitorenter
       7: aload_0
       8: dup
       9: getfield      #2                  // Field count:I
      12: iconst_1
      13: iadd
      14: putfield      #2                  // Field count:I
      17: aload_1
      18: monitorexit
      19: goto          27
      22: astore_2
      23: aload_1
      24: monitorexit
      25: aload_2
      26: athrow
      27: return
}  

可以看到Test类中的incr方法编译后,会在第6行插入一个monitorenter指令,在第15行和第24行插入的monitorexit指令,为什么会插入两个monitorexit指令呢?第15行的monitorexit指令对应了synchronized代码块正常退出的时候,需要执行到的monitorexit指令,第24行对应了当synchronized代码块中抛出异常时,需要执行的monitorexit指令,因为jvm必须保证每个monitorenter指令必须有与之对应的monitorexit指令,所以生成的字节码中会插入两个monitorexit指令。

我们再看一下当多个线程同时执行incr方法时,synchonized是如何利用共享变量的内存可见性和同步代码块的原子性来保证多线程执行时的线程安全的?

当线程A执行到第6行的monitorenter指令时,会尝试申请获取lock对象对应的的monitor对象的所有权,此时monitor对象还没有被其它线程持有,因此线程A成功获取了monitor对象的所有权,也就是获得了锁对象,线程A获取了锁后会进入同步代码块,线程A首先会将本地内存置为无效,然后从主内存中读取count的值到本地内存中执行this.count+=1的操作。

此时如果线程B也执行到第6行的monitorenter指令,此时线程B也会尝试获取lock对象对应的monitor对象的所有权,但是由于monitor对象已经被线程A获取,所以线程B会由于获取失败而进入阻塞状态。

当线程A执行完this.count+=1的代码时,会得到count的最新值为1,接下来线程A执行到第15行代码的monitorexit指令时会将count=1写回主内存中,同时释放monitor对象的所有权,也就是释放了锁。

由于线程A执行完synchronized修饰的代码块后释放了monitor锁对象,此时线程B会重新被唤醒,线程B重新尝试获取monitor对象的所有权,此时monitor对象没有被其它线程获取,因此线程B可以成功获取到monitor对象,之后线程B也进入同步代码块后将本地内存置为无效,然从主内存中将count的最新值读取到本地内存中,此时读取到的count值为1,然后线程B执行count+1操作,得到count的值为2,当线程B执行到第15行代码的monitorexit指令时会将count=2的最新值写入到主内存中,然后会释放monitor对象的所有权,此时主内存中的count值为2,也就得到了正确的结果。

可以看到synchronized关键字通过保证了共享变量的内存可见性和方法执行的原子性,从而避免了多线程的线程安全问题。

总结

本篇文章作为《 》的下篇,讲解了synchronized可是如何保证线程安全的,主要从共享变量的内存可见性和方法执行的原子性两方面去讲解,希望大家看完能有所收获。

关注小编,获取更多技术原理知识!

文章来源:智云一二三科技

文章标题:Java中为什么synchronized能保证线程安全呢?

文章地址:https://www.zhihuclub.com/181148.shtml

关于作者: 智云科技

热门文章

网站地图