1.synchronized简介

在学习知识前,我们先来看一个现象:

  1. public class SynchronizedDemo implements Runnable{
  2. private static int count = 0;
  3. public static void main(String[] args) {
  4. for (int i = 0 ; i < 10 ;i++){
  5. Thread thread = new Thread(new SynchronizedDemo());
  6. thread.start();
  7. }
  8. try {
  9. Thread.sleep(500);
  10. } catch (InterruptedException e) {
  11. e.printStackTrace();
  12. }
  13. System.out.println("result:"+count);
  14. }
  15. @Override
  16. public void run() {
  17. for (int i = 0 ; i < 1000000 ; i++)
  18. count++;
  19. }
  20. }
  1. 开启了10个线程,每个线程都累加了1000000次按理说如果正常的话运行结果应该是10*10000000。但是预期结果缺不如我们想的那样,运行截图如下:<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/22732206/1650244480130-a8cc1368-4bbd-4d1c-a809-d42b6896f128.png#clientId=ud304b4b1-3cc4-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=107&id=ubaf6d589&margin=%5Bobject%20Object%5D&name=image.png&originHeight=133&originWidth=565&originalType=binary&ratio=1&rotation=0&showTitle=false&size=8304&status=done&style=none&taskId=ua3279dd1-c8c4-42e7-bbf6-ec8e9a310da&title=&width=455.7983047165332)<br />而且每次运行的结果都不一样,这到底是为什么呢?<br />其实出现线程安全的主要来源于JMM的设计,主要集中在主内存和线程的工作内存而导致的**内存可见性问题**,以及**重排序导致的问题,**那么共享数据的线程安全问题怎么处理呢?很自然的想法就是每一个线程依次去读写这个共享变量,这样就不会有任何数据安全的问题,因为每个线程所操作的都是最新版本数据。那么,在Java关键字synchronized就具有使每个线程依次排队操作共享变量的功能。

2.Synchronized实现原理

在java代码中使用synchronized可是使用在代码块和方法中,根据Synchronized的位置可以有这些使用场景:
image.png
如图,synchronized可以用在方法上也可以使用在代码块中,其中方法是实例方法和静态方法分别锁的是该类的实例对象和该类的对象。而使用在代码块中也可以分为三种,具体的可以看上面的表格。这里的需要注意的是:如果锁的是类对象的话,尽管new多个实例对象,但他们仍然是属于同一个类依然会被锁住,即线程之间保证同步关系。

2.1对象锁(monitor)机制

  1. public class Synchronizedtext2 {
  2. public static void main(String[] args) {
  3. synchronized (Synchronizedtext2.class){
  4. }
  5. method();
  6. }
  7. private static void method(){
  8. }
  9. }

通过反编译看字节码文件。
image.png
每一个对象都会和一个监视器monitior关联,监视器被占用时会被锁住,其他线程无法获取该monitor,当JVM执行某个线程的某个方法内部的monitorenter时,它会尝试去获取当前对象对应的monitor的所有权,其过程如下:
1.若monitor的进入数为0,线程可以进入monitor时,并将monitor的进入数量为1,当前线程成为monitor的owner(所有者)
2.若线程已拥有monitor的所有权,允许它重入monitor,则进入monitor的进入参数加1
3.若其他线程已经占有monitor的所有权,那么当前尝试获取monitor的所有权的线程会被阻塞,直到monitor的进入数变为0,才能重新尝试获取monitor的所有权。
任意一个对象都拥有自己的监视器,当这个对象由同步块或者这个对象的同步方法调用时,执行方法的线程必须先获取该对象的监视器才能进入同步块和同步方法,如果没有获取到监视器的线程将会被阻塞在同步块和同步方法的入口处,进入到BLOCKED状态、
image.png

2.2 Synchronized的happens-before关系

监视器锁规则:对同一个监视器的解锁,happens-before于该监视器的加锁。继续看代码:

  1. public class MonitorDemo {
  2. private int a = 0;
  3. public synchronized void writer() { // 1
  4. a++; // 2
  5. } // 3
  6. public synchronized void reader() { // 4
  7. int i = a; // 5
  8. } // 6
  9. }

该代码的happens-before关系如图所示:
image.png
在图中每一个箭头连接的两个节点就代表之间的happens-before关系,黑色的是通过程序顺序规则推导出来,红色的为监视器锁规则推导而出:线程A释放锁happens-before线程B加锁,蓝色的则是通过程序顺序规则和监视器锁规则推测出来happens-befor关系,通过传递性规则进一步推导的happens-before关系。现在我们来重点关注2 happens-before 5,通过这个关系我们可以得出什么?

根据happens-before的定义中的一条:如果A happens-before B,则A的执行结果对B可见,并且A的执行顺序先于B。线程A先对共享变量A进行加一,由2 happens-before 5关系可知线程A的执行结果对线程B可见即线程B所读取到的a的值为1。

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

image.png
从上图可以看出,线程A会首先先从主内存中读取共享变量a=0的值然后将该变量拷贝到自己的本地内存,进行加一操作后,再将该值刷新到主内存,整个过程即为线程A 加锁—>执行临界区代码—>释放锁相对应的内存语义。
image.png
线程B获取锁的时候同样会从主内存中共享变量a的值,这个时候就是最新的值1,然后将该值拷贝到线程B的工作内存中去,释放锁的时候同样会重写到主内存中。

从整体上来看,线程A的执行结果(a=1)对线程B是可见的,实现原理为:释放锁的时候会将值刷新到主内存中,其他线程获取锁时会强制从主内存中获取最新的值。另外也验证了2 happens-before 5,2的执行结果对5是可见的。

从横向来看,这就像线程A通过主内存中的共享变量和线程B进行通信,A 告诉 B 我们俩的共享数据现在为1啦,这种线程间的通信机制正好吻合java的内存模型正好是共享内存的并发模型结构。

3.Synchronized的优化

Synchronized最大的特征就是在同一时刻只有一个线程能够获得对象的监视器(monitor),从而进入到同步代码块或者同步方法中,即表现为互斥性(排它性)。这种方式肯定效率低下。每次只能通过一个线程,既然每次只能通过一个,这种形式不能改变的话,那么我们能不能让每次通过的速度快一点。举个简单的例子:超市的收营员以前是顾客排队给钱,需要找零之内的,花费的时间比较多,现在通过扫码支付,省去了找零的风险。这种优化java也做了类似的。

3.1 CSA操作

3.1.1什么是CSA?

使用锁时,线程获取锁是一种悲观锁策略,即假设每一次执行临界区代码都会产生冲突,所以当前线程获取到锁的时候同时也会阻塞其他线程获取该锁。而CAS操作(又称为无锁操作)是一种乐观锁策略,它假设所有线程访问共享资源的时候不会出现冲突,既然不会出现冲突自然而然就不会阻塞其他线程的操作。因此,线程就不会出现阻塞停顿的状态。那么,如果出现冲突了怎么办?无锁操作是使用CAS(compare and swap)又叫做比较交换来鉴别线程是否出现冲突,出现冲突就重试当前操作直到没有冲突为止。

3.1.2 CAS的操作过程

CAS比较交换的过程可以通俗的理解为CAS(V,O,N),包含三个值分别为:V 内存地址存放的实际值;O 预期的值(旧值);N 更新的新值。当V和O相同时,也就是说旧值和内存中实际的值相同表明该值没有被其他线程更改过,即该旧值O就是目前来说最新的值了,自然而然可以将新值N赋值给V。反之,V和O不相同,表明该值已经被其他线程改过了则该旧值O不是最新版本的值了,所以不能将新值N赋给V,返回V即可。当多个线程使用CAS操作一个变量是,只有一个线程会成功,并成功更新,其余会失败。失败的线程会重新尝试,当然也可以选择挂起线程
CAS的实现需要硬件指令集的支撑,在JDK1.5后虚拟机才可以使用处理器提供的CMPXCHG指令实现。
元老级的Synchronized(未优化前)最主要的问题是:在存在线程竞争的情况下会出现线程阻塞和唤醒锁带来的性能问题,因为这是一种互斥同步(阻塞同步)。而CAS并不是武断的间线程挂起,当CAS操作失败后会进行一定的尝试,而非进行耗时的挂起唤醒的操作,因此也叫做非阻塞同步。这是两者主要的区别。

3.1.3 CAS的问题

1.ABA的问腿

因为CAS会检查旧值有没有变化,这里存在这样一个有意思的问题。比如一个旧值A变为了成B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化。解决方案可以沿袭数据库中常用的乐观锁方式,添加一个版本号可以解决。原来的变化路径A->B->A就变成了1A->2B->3C。java这么优秀的语言,当然在java 1.5后的atomic包中提供了AtomicStampedReference来解决ABA问题,解决思路就是这样的。

2.自旋时间过长

使用CAS时非阻塞同步,也就是说不会将线程挂起,会自旋(无非就是一个死循环)进行下一次尝试,如果这里自旋时间过长对性能是很大的消耗。如果JVM能支持处理器提供的pause指令,那么在效率上会有一定的提升。

3.只能保证一个共享变量的原子操作

当对一个共享变量执行操作时CAS能保证其原子性,如果对多个共享变量进行操作,CAS就不能保证其原子性。有一个解决方案是利用对象整合多个共享变量,即一个类中的成员变量就是这几个共享变量。然后将这个对象做CAS操作就可以保证其原子性。atomic中提供了AtomicReference来保证引用对象之间的原子性。

3.2Java对象头

在同步的时候是获取对象的monitor,即获取到对象的锁,那
image.png