Java的并发关键字

Java并发关键字-synchronized

Java关键字synchronized具有使每个线程一次排队操作共享变量的功能,很显然,这种同步机制效率很低,但synchronized是其他并发容器实现的基础。

实现原理

使用场景:
在这里插入图片描述
tip:

  • synchronized可以用在方法上也可以使用在代码块中,其中方法是实例方法和静态方法分别锁的是该类的实例对象和该类的对象。
  • 如果锁的是类对象的话,尽管new多个实例对象,但他们仍然是属于同一个类依然会被锁住,即线程之间保证同步关系。

对象锁(monitor)机制

在这里插入图片描述
如图,上面用黄色高亮的部分就是需要注意的地方,这是添加synchronized关键字后独有的。执行同步代码块后首先要执行monitorenter指令,退出的时候monitorexit指令。使用synchronized进行同步,其关键就是必须要对对象的监视器monitor进行获取,当前线程获取到monitor后才能继续往下执行,否则就只能等待。而这个过程是互斥的,即同一时刻只有一个线程能获取到monitor。上面的demo中执行同步代码块之后紧接着再会去执行一个静态同步方法。而这个方法锁的对象依然就这个类对象,那么这个正在执行的线程还需要获取该锁吗?答案是不必的,这就是锁的可重入性。即在同一锁程中,线程不需要再次获取同一把锁。synchronized先天具有重入性,每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一。

任意一个对象都拥有自己的监视器,当这个对象由同步块或者这个对象的同步方法调用时,执行方法的线程必须先获取该对象的监视器才能进入同步块和同步方法,如果没有获取到监视器的线程将会阻塞在同步块和同步方法入口处,进入BLOCKED状态。

下图表示了对象,对象监视器,同步队列以及执行线程状态之间的关系:
在这里插入图片描述
由图可以得出,任意线程对Object得访问,首先要获得Object得监视器,如果获取失败,该线程就进入同步状态,线程状态变为BLOCKED,当Object得监视器占有者释放后,在同步队列中的线程就会有机会重新获取该监视器。

synchronized的happens-before关系

一个简单的happens-before关系如下
在这里插入图片描述
在图中每个箭头连接的两个节点就代表之间的happens-before关系,黑色的是通过程序顺序规则推导出来,红色的为监视器锁规则推导出来的:线程A释放锁happens-before线程B加锁,蓝色的则是通过程序顺序和监视器锁规则推测出来happens-before关系,通过传递性规则进一步推导的happens-before关系。
从2happens-before 5可以得出什么?
根据happens-before的定义中的一条,如果A happens-before B,则A的执行结果对B可见,并且A的执行顺序先于B。线程A先对共享变量A进行加一,由2 happens-before 5关系可知线程A的执行结果对线程B可见即线程B所读取的a的值为1。(原本为0)

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

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

  • 从整体上看:线程A的执行结果(a=1)对线程B是可见的,实现原理为:释放锁的时候会将值刷新到主内存中,其他线程获取锁是会强制从主内存中获取最新的值,另外也验证了2happens-before 5,2的执行结果到5是可见的。
  • 从横向来看:这就像线程A通过主内存中的共享变量和线程B进行通信,A告诉B我们俩的共享数据现在为1啦。这种线程间的通信机制正好符合Java的内存模型,正好是共享内存的并发模型结构。

synchronized优化

synchronized最大的特征就是在同一时刻只有一个线程能够获得对象的监视器(monitor),从而进入到同步代码块或同步方法之中,即表现为互斥性(排他性)。这种方式肯定是低下的,只能通过一个线程,即每次只能通过一个这种形式不能改变的话,那我们能不能让速度更块一小点呢?
这就要引入锁优化

CAS操作

  • 什么是CAS?
    使用锁的时候,线程获取锁是一种悲观策略,即假设每次执行临界区代码都会产生冲突,所以当前线程获取到锁的时候也会阻塞其他线程获取锁。而CAS操作(又称为无锁操作)是一种乐观锁策略,它假设所有线程访问共享资源的时候不会出现冲突,既然不会出现冲突自然而然就不会阻塞其他线程的操作。因此,线程就不会出现阻塞停顿的状态。那么,如果出现冲突了怎么办?无锁操作时使用CAS(compare and swap)又叫做比较交换来鉴别线程是否出现冲突,出现冲突就重试当前操作直到没有冲突为止。
  • CAS的操作过程
    CAS比较交换的过程可以可以通俗的理解为CAS(V,O,N),包含三个值分别为:**V内存地址存放的实际值;O预期的值(旧值);N更新的值。**当V和O相同时,也就是说旧值和内存中实际的值相同表明该值没有被其他线程更改过,即该旧值O就是目前来说最新的值,自然而然就可以将新值赋值给V。反之,V和O不相同,表明该值已经被其他线程改过了则该旧值O不是最新版本的值,所以不能将新值N赋给V,返回V即可。当多个线程使用CAS操作一个变量时,只有一个线程会成功,其余会失败。失败的线程会重新尝试,当然也可以选择挂起线程。
  • 元老级的synchronized(未优化前)最主要的问题:在存在线程竞争下会出现线程阻塞和唤醒锁带来的性能问题,因为这是一种互斥同步(阻塞同步)。而CAS并不是武断的将线程挂起,当CAS操作失败后会进行一定的尝试,而非进行耗时的挂起唤醒操作,因此也叫做非阻塞同步。这是两者主要的区别。
  • CAS的应用场景
    在J.U.C包中利用CAS实现类有很多,可以说是支撑了整个concurrency包的实现,在Lock实现中会有CAS改变state变量,在atomic包中实现类也几乎都是用CAS实现的。

CAS的问题

1.ABA问题
因为CAS会检查旧值有没有变化,这里存在这样一个有意思的问题,比如一个旧值A变为了B,然后再变成了A,刚好在做CAS操作时检查发现旧值并没有发生变化依然为A,但实际上的确发生了变化,解决方案可以沿用数据库中常用的乐观锁方式,添加一个版本号可以解决,原来的变化路径A->B->A就变成了1A->2B->3C。在Java1.5后的atomic包中提供了AtomicStampedReference来解决ABA问题,思路就是前面提到的那样。
2.自旋时间过长
使用CAS时非阻塞同步,也就是说不会将线程挂起,会自旋(其实就是一个死循环)进行下一次尝试,如果这里自旋时间过长对性能是很大的消耗。
3.只能保证一个共享变量的原子操作
当对一个共享变量执行操作时CAS能保证其原子性,如果对多个共享变量进行操作,CAS就不能保证其原子性。有一个解决方案就是利用对象整合多个共享变量,即一个类中的成员变量就是这几个共享变量,然后将这个对象做CAS操作就可以保证其原子性,atomic中提供了AtomicRefrenence来保证引用对象之间的原子性。

Java对象头

在同步的时候是获取对象的monitor,即获取到对象的锁。那么对象的锁怎么理解?无非就是类似对对象的一个标志,那么这个标志就是存放在Java对象的对象头,Java对象头里的Mark Word里默认的存放对象的hashcode,对象分代年龄和锁标记位。32位JVM Mark Word默认存储结构为:
在这里插入图片描述
在Java SE1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态,偏向锁状态,轻量级锁状态,重量级锁状态,这几个状态会随着竞争情况逐渐升级。**锁可以升级,但不能降级,**意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。对象的MarkWord变化为下图:
在这里插入图片描述

偏向锁

  • 偏向锁的获取
    当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程再进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单地测试一下对象头里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁,如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象的偏向锁指向当前线程。
  • 偏向锁的撤销
    偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
    在这里插入图片描述
    如图,偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它首先会暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否还存活,如果线程不处于活动状态,则将对象头设置成无锁状态;如果对象仍然存活,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的所记录和对象头的Mark Word要么重新偏向于其他线程,要么回复到无锁或标记对象不适合作为偏向锁,最后唤醒暂停的线程。
    在这里插入图片描述
  • 如何关闭偏向锁
    偏向锁在Java6和7里面时默认启用的,但是它会在应用启动几秒后才激活,如果有必要可以用JVM参数来关闭延迟:
    -XX:BiasedLockingStartupDelay=0。
    如果你确定应用程序里面所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:
    -XX:UseBiasedLocking=false,那么程序默认会进入轻量锁状态。

轻量级锁

  • 加锁
    线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针,如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
  • 解锁
    轻量级锁解锁时,会使用原子的CAS操作将DIsplaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生,如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。下图是两个线程同时争夺锁,导致锁膨胀的流程图:
    在这里插入图片描述
    因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

锁的比较

在这里插入图片描述
例子:
在这里插入图片描述
开启十个线程,每个线程在原值上累加1000000次,最终正确的结果为10X1000000=10000000,这里能够计算出正确的结果是因为在做累加操作时使用了同步代码块,这样就能保证每个线程所获得共享变量的值都是当前最新的值,如果不使用同步的话,就可能会出现A线程累加后,而B线程做累加操作有可能是使用原来的就值,即“脏值”。这样,就导致最终的计算结果不是正确的。而使用syncnized就可能保证内存可见性,保证每个线程都是操作的最新值。

Java并发关键字-volatile

被volatile修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免出现数据脏读的现象。

实现原理

volatile是如何实现的,比如

instance = new Instance() //instance是volatile变量

在生成汇编代码时会在volatile修饰的共享变量进行写操作的时候会多出Lock前缀指令,那么Lock前缀指令在多核处理器下会发生什么事情,主要有这两个方面的影响:
1.将当前处理器缓存行的数据写回系统内存;
2.这个写回内存的操作会使其他CPU里缓存了改内存地址的数据无效
为了提高处理熟读,处理器不直接和内存继续宁通信,而是先将系统内存的数据读到内存缓存后再进行操作,但操作不知道何时会写道内存,如果声明了volatile变量进行写操作,JVM就会向处理器发送Lock浅醉的指令,将这个变量所在缓存行的数据写回到内存,但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以,在多处理器下,为了保证各个处理器的缓存时一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里,因此,可以得出以下结论:
1.Lock前缀指令会引起处理器缓存写回内存
2.一个处理器的缓存回写到内存会导致其他处理器的缓存失效;
3.当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值
这样针对volatitle变量通过这样的机制就使得每个线程都能获得该变量得最新值。

volatile的happens-before关系

在六条happens-before规则中有一条是:volatile域得写,happens-before于任意后续对这个volatile域得写。
在这里插入图片描述
上面代码所对应得happens-before关系
在这里插入图片描述
加锁线程A先执行write方法,然后线程B执行reader方法,图中黑色得代表根据程序顺序规则推导出来,红色是根据volatile变量得写happens-before于任意后续对volatile得读,而蓝色得是根据传递性规则推导出来得,这里2 happens-before3 ,同样根据happens-before规则定义,如果Ahappens-beforeB ,则A得执行结果对B可见,并且A的执行顺序先于B,因此操作2执行结果对操作3来说是可见的,也就是说当线程A将volatile变量flag更改为true后线程B就能够迅速感知。

volatile的内存语义

还是按照两个核心(JMM内存模型和heppens-before规则)的分析方式,分析完heppens-before关系后来进一步分析volatile的内存语义。假设线程A先执行write()方法,线程B随后执行reader方法,初始时线程的本地内存中flag和a都是初始状态,下面是线程A执行volatile写后的状态图:
在这里插入图片描述
当volatile变量写后,线程中本地内存中共享变量就会置为失效状态,因此线程B再需要读取从主内存中去读取该变量的最新值。下图展示了线程B读取同一个volatile变量的内存变化示意图:

在这里插入图片描述
从横向来看,线程A和线程B之间进行了依次通信,线程A在写volatile变量时,实际就像是给B发送了一条消息告诉线程B你现在的值都是旧的了,然后线程B读这个变量时就像接受了线程A刚刚发送的消息。既然是旧的,线程B就只能去主内存去取了。

volatile的内存语义实现

我们都知道,为了性能优化,JMM在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序,那如果想要阻止重排序怎么办?那么就可以添加内存屏障。

内存屏障

JMM内存屏障分为下面4类
在这里插入图片描述
Java编译器会在生成指令系列时在适当的位置插入内存屏障指令来禁止特定类型的处理器重排序。为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器重排序,JMM会针对编译器指定volatile重排序规则表:
在这里插入图片描述
“NO”表示禁止重排序,为了实现volatile内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序,对于编译器来说,发现一个最优布置来最小化插入屏障的总是是不可能的,因此,JMM采取了保守策略:
1.在每个volatile写操作的前面插入一个StroeStroe屏障;
2.在每个volatile写操作的前面插入一个StroeLoad屏障;
3.在每个volatile读操作的前面插入一个LoadLoad屏障;
4.在每个volatile读操作的前面插入一个LoadStroe屏障;
需要注意:volatile写是在前后都分别插入了内存屏障,而volatile读操作是在后面插入了两个内存屏障
StoreStore屏障:禁止上面的普通写和下面的volatile写重排序;
StoreLoad屏障:防止上面的volatile写与下面可能有的volatile读/写重排序
LoadLoad屏障:禁止下面所有的普通读操作和上面的volatile读重排序
LoadStroe屏障:禁止下面所有的普通写操作和上面的volatile读重排序
在这里插入图片描述
在这里插入图片描述
例子:
在这里插入图片描述
注意点:里面的isOver是volatile变量,这样在main线程中将isOver改成true后,thread的工作内存该变量值就会失效,从而需要再次从主内存中读取该值,现在能够读出isOver最新值为true从而能够结束在thread里的死循环,从而能够顺利停止掉thread线程。

Java并发关键字-final

final可以修饰变量,方法和类,用于表示所修饰的内容一旦赋值之后就不会再改变,比如String类就是一个final类型的类,即使能够知道final具体的使用方法,final在多线程中存在的重排序问题很容易忽略。

final的具体使用场景

变量

在Java中变量,可以分为成员变量以及方法局部变量。因此也是按照这种方式依次来说,以避免漏掉任何一个死角。

  • final成员变量
    通常每个类中的成员变量可以分为类变量(static修饰的变量)以及实例变量。针对这两种类型的变量赋初值的时机是不同的,类变量可以在声明的时候直接赋初值或者在静态代码块中给类变量赋初值。而实例变量可以在声明变量的时候给实例变量赋初值,在非静态初始化块中以及构造器中赋初值。类变量有两个时机赋初值,而实例变量则可以有三个时机赋初值。当final变量未初始化时系统不会进行隐式初始化,会出现报错。下面会列出一些情况:
    在这里插入图片描述
    从图中可以得出:
    1.类变量:必须要在静态初始化块中指定初始值或者声明该类变量时指定初始值,而且只能在这两个地方之一进行指定;
    2.实例变量:必须要在非静态初始化块,声明该实例变量或者在构造器中指定初始值,而且只能在这三个地方进行指定。
  • final局部变量
    final局部变量由程序员进行显示初始化,如果final局部变量已经进行了初始化则后面就不能再次进行更改,如果final变量未进行初始化,可以进行赋值,当且仅有依次赋值,一旦赋值之后再次赋值就会出错,如下例子:
    在这里插入图片描述
  • final修饰的基本数据类型和引用类型有区别吗?
    final基本数据类型VSfinal引用数据类型
    在这里插入图片描述
    当我们对final修饰的引用数据类型变量person的属性改成22,是可以成功操作的。通过此可以得知,当final修饰基本数据类型变量时,不能对基本数据类型变量重新赋值,因此基本数据类型变量不能被改变。而对于引用类型变量而言,它仅仅保存了一个引用,final只保证这个引用类型变量所引用的地址不会发生改变,即一直引用这个对象,但是这个对象属性是可以改变的。
  • 宏变量
    利用final变量的不可更改性,在满足以下三个条件时,该变量就会成为一个“宏变量”,即是一个常量。
    1.利用final修饰符修饰;
    2.在定义该final变量时就指定了初始值;
    3.该初始值在编译时就能够唯一指定;
    注意:当程序中其他地方使用该宏变量的地方,编译器会直接替换成该变量的值

方法

  • 重写?
    当父类的方法被final修饰的时候,子类不能重写父类的该方法,比如在Object中,getClass()方法就是final的,我们就不能重写该方法,但是hashCode()方法就不是被final所修饰的,我们就可以重写hashCode()方法。
    例子:
    先定义一个父类,里面有final修饰的方法
    在这里插入图片描述
    然后继承该类,重写test就会报错
    在这里插入图片描述
  • 重载?
    在这里插入图片描述
    可以看出被修饰的方法是可以被重载的,得出:
    1.父类的final方法是不能被子类重写的
    2.final方法是可以被重载的

当一个类被final修饰时,表明该类是不能被子类继承的。子类继承往往可以重新给父类的方法和改变父类的属性,会带来一定的安全隐患,因此,当一个类不希望被继承时就可以使用final修饰
在这里插入图片描述
父类被final继承时,子类继承父类就会报错
在这里插入图片描述

final关键字举例

final经常会被用作不变类上,利用final的不可更改性

  • 不变类
    不变类的意思是创建该类的实例后,该实例的实例变量是不可改变的。满足以下条件就是不可变类:
    1.使用private和final修饰符来修饰该类的成员变量;
    2.提供带参的构造器用于初始化类的成员变量;
    3.仅为该类的成员变量提供getter方法,不提供setter()方法,因为普通方法无法修改final修饰的成员变量;
    4.如果有必要就重写Object类的hashCode()和equals()方法,应该保证用equals()判断相同的两个对象其HashCode值也是相等的
    JDK中提供的八个包装类和String类都是不可变类
    在这里插入图片描述
    可以看出String 的value就是final修饰的

final域重排序规则

  • final域为基本类型
    在这里插入图片描述
    假设线程A在执行write(),线程B在执行reader()方法
    写final域重排序规则
    写final域的重排序规则禁止对final域的写重排序到构造函数之外,这个规则的实现主要包含了两个方面:
    1.JMM禁止编译器把final域的写重排序到构造函数之外
    2.编译器会在final域写之后,构造函数return之前,插入一个storestore屏障,这个屏障可以禁止处理器把final域的写重排序到构造函数之外。
    我们再来分析以下write方法,虽然只有一行代码,但实际做了两件事
    1.构造了一个FinalDemo对象;
    2.把这个对象赋值给成员变量finalDemo
    在这里插入图片描述
    由于a,b之间没有数据依赖性,普通域(普通变量)a可能会被重排序到构造函数之外,线程B就有可能读到的是普通变量a初始化之前的值(零值),这样就可能出现错误。而final域变量b根据重排序规则,会禁止final修饰的变量b重排序到构造函数之外,从而b能够正确赋值,线程B就能够读到final变量初始化后的值。
    因此,写final域的重排序规则则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域就不具有这个保障

读final域重排序规则
读final域重排序规则为:在一个线程中,初次读对象引用和初次读该对象包含的final域,JMM会禁止这两个操作的重排序,这个规则仅仅针对处理器,处理器会在读final操作的钱买你插入一个LoadLoad屏障。实际上,读对象的引用和读该对象的final域存在间接依赖性,一般处理器不会重排序这两个操作。但有些处理器会进行重排序,因此,这条禁止重排序规则就是针对这些处理器设定的
read()方法主要包含以下三个操作
1.初次读引用变量finalDemo;
2.初次读引用变量finalDemo的普通域a;
3.初次读引用变量finalDemo的final与b;
假设线程A写过程没有重排序,那么线程A和线程B有一种的可能执行时序为下图:
在这里插入图片描述
读对象的普通域被重排序到了都对象以后用的前面就会出现线程B还未读到该对象引用就在读取该对象的普通域变量,这显然是错误的操作,而final域的读操作就限定了在读final域变量前已经读到了该对象的引用,从而就可以避免这种情况,读final域的重排序规则则可以确保:在读一个对象的final域之前,一定会先读这个包含final域的对象的引用。

关于final重排序的总结

按照final修饰的数据类型分类:
基本数据类型:
1.final域写:禁止final域写与构造方法重排序,即禁止final域写重排序到构造方法之外,从而保证该对象对所有线程可见时,该对象的final域全部已经初始化过。
2.final域读:禁止初次读对象的引用与读该对象包含的final域的重排序
引用数据类型:
额外增加约束:禁止在构造函数对一个final修饰的对象的成员域的写入与随后将这个构造的对象的引用赋值给引用变量重排序。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值