Java内存模型

文章参考 《Java并发编程实战》

原子性

  假定有两个操作A和B,如果从执行A的线程来看,当另一个线程执行B时,要么将B全部执行完,要么完全不执行B,那么A和B对彼此来说都是原子的。原子操作是指,对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作是一个以原子方式执行的操作。通俗的说就是单一不可分割的操作。

  Java内存模型只保证了基本读取和赋值是原子性操作,当需要其他原子性操作时,可以使用synchronized关键字。Java为了解决单一数据被多个线程同时访问导致访问冲突的问题,提供了synchronized同步机制。synchronized确保被synchronized修饰的语句或代码块在同一时间只会被单一线程访问,从而被synchronized修饰的代码块可以视为一个原子操作。

内存可见性

  我们已经知道了同步代码块和同步方法(被synchronized修饰的代码块和方法)可以确保以原子的方式执行操作,但一种常见的误解是,认为关键字synchronized只能用于实现原子性或者确定“临界区(Critical Section)”。同步还有另一个重要的方面:“内存可见性”我们不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改该状态,而且希望确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化。如果没有同步,那么这种情况就无法实现。你可以通过显式的同步或者类库中内置的同步(java.util.concurrent)来保证对象被安全地发布。

重排序

  我们平常编程运行的时候会错以为编译的顺序与编写的顺序是一样的,但是编译器和处理器为了提高性能,常常会对指令进行重排序。
  重排序的类型分为三类:

     - 编译器优化的重排序
     - 指令级并行的重排序
     - 内存系统的重排序

  Java源程序从编译到执行之间会经过这三次重排序。

顺序一致性

  顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:
    - 一个线程中的所有操作必须按照程序的顺序来执行。
    - (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存 模型中,每个操作都必须原子执行且立刻对所有线程可见。

顺序一致性内存模型为程序员提供的视图如下:
这里写图片描述
在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程。同时,每一个线程必须按程序的顺序来执行内存读/写操作。从上图我们可以看出,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读/写操作串行化。

为了更好的理解,下面我们通过两个示意图来对顺序一致性模型的特性做进一步的说明。

假设有两个线程A和B并发执行。其中A线程有三个操作,它们在程序中的顺序是:A1->A2->A3。B线程也有三个操作,它们在程序中的顺序是:B1->B2->B3。

假设这两个线程使用监视器来正确同步:A线程的三个操作执行后释放监视器,随后B线程获取同一个监视器。那么程序在顺序一致性模型中的执行效果将如下图所示:
这里写图片描述
现在我们再假设这两个线程没有做同步,下面是这个未同步程序在顺序一致性模型中的执行示意图:
这里写图片描述
未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程A和B看到的执行顺序都是:B1->A1->A2->B2->A3->B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。

但是,在JMM中就没有这个保证。未同步程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,且还没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本还没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其它线程看到的操作执行顺序将不一致。

volatile

原理

  volatile是Java提供的一种稍弱的同步机制,用来确保将变量的更新操作同时到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。当volatile变量被修改时,修改后的值会被写入主内存(RAM),volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。要注意的是volatile只能保证内存可见性,不能保证原子性。而synchronized不止可以保证内存可见性,还可以保证原子性。

  线程在读写非volatile变量时会先把变量从内存拷贝到cpu缓存中,如果电脑有多个cpu,每个cpu有自己的cpu缓存,说明线程会在不同的cpu中运行并将数据拷贝到不同的cpu缓存。当其他cpu的线程修改变量时,另外的cpu中的线程由于还是会从所处的cpu的缓存中读取数据,导致数据不一致。
  当读写的变量是volatile修饰的时候,Java虚拟机(JVM)会提示你直接到主内存中读取变量,而不是从cpu缓存中读取。

  在访问volatile变量时不会执行加锁操作,不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。

特性

  • 可见性:修改被volatile修饰的变量时,会及时同步更新到主内存。读取被volatile修饰的变量时,会直接从跳过cpu缓存从主内存中读取。
  • 禁止指令重排序:当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序

与synchronized异同

  • 1、volatile不会导致线程阻塞,synchronized会导致线程阻塞
  • 2、volatile禁止编译器对变量的操作进行优化(指令重排序),synchronized标记的变量可以被编译器优化
  • 3、volatile只能修饰变量,synchronized可以修饰变量,方法和类等
  • 4、volatile只能确保可见性,无法确保原子性。synchronized可以同时确保可见性和原子性
  • 5、volatile本质上是告诉线程不要在工作内存(寄存器)取值,而是直接到主内存读取。synchronized是将修饰的变量、代码块、方法等锁住,同一时间只允许单个线程进行读取,其他线程需要等待上一线程释放锁。

锁的类型

  如果从宏观分类,那么锁类型有两种,分别是乐观锁和悲观锁。

乐观锁

  乐观锁,顾名思义,对数据的高并发冲突保持乐观态度,认为写少读多,发生并发写的概率较小,每次读数据的时候认为数据不会被其他线程修改,不对数据上锁,只在提交操作时检查是否违反数据完整性。更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则重复读-比较-写的操作。

悲观锁

  悲观锁指对情况发展时刻抱有悲观的思想,认为写多读少,发生并发写的概率较大,每次读数据都认为数据会被其他人修改,所以每次读取数据时都会将数据上锁,其他线程如果读取数据会变成阻塞状态,直到获得被释放的锁。

Java中的锁

  Java中的悲观锁就是synchronized。在Java中,使用synchronized会导致抢不到锁的线程进入阻塞状态,这是一个重量级的同步操作,所以synchronized也被称为重量级锁。Java为了解决synchronized带来的性能问题,在jdk1.5之后(包含1.5)的版本引入了自旋锁、偏向锁和轻量级锁,重量级锁是悲观锁,自旋锁、偏向锁和轻量锁是乐观锁,不同的锁有不同的特点,对应不同的业务需求。

重量级锁

  Java中的重量级锁是synchronized。synchronized可以修饰方法、类等。

  • **修饰方法:**锁住对象的实例(this)。
  • **修饰静态方法:**锁住class实例,因为class的相关数据保存在永久代PermGen(jdk1.8取消永久代改成metaspace),永久代全局共享,所以静态方法锁相当于类的全局锁,锁调用该方法的所有线程。
  • **修饰对象实例:**锁所有以该对象为例的代码块。

  Synchronized是非公平锁。先等待锁的线程不一定先获得锁。

自旋锁

  jdk1.6启动自旋锁可以通过配置-XX:+UseSpinning启动,-XX:PreBlockSpin=10 为自旋次数,默认为10次,jdk1.7之后由jvm控制启动。

  自旋锁的原理是让线程循环观察(自旋)锁的持有者是否已经释放锁,不让自身处于阻塞状态NON-BLOCKING)。注意:自旋时占用cpu资源,不能永久循环,需要设置最大等待时间,超过最大等待时间后线程停止自旋进入阻塞状态。

  • 优点:自旋锁减少线程的阻塞,减轻锁的竞争情况,大幅度提升占用锁时间非常短的代码块的性能,因为自旋避免了用户线程与内核之间切换的消耗。
  • 缺点:当线程自旋的消耗 > 线程阻塞挂起的消耗,此时不适合使用自旋锁。例如,锁的竞争十分激烈,线程需要自旋非常长的时间或者持有锁的线程长时间占用锁调用同步代码块。
偏向锁

  Java偏向锁(Biased Locking)是Java6引入的一项多线程优化。它通过消除资源无竞争情况下的同步原语,进一步提高了程序的运行性能。
  偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。
  如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会尝试消除它身上的偏向锁,将锁恢复到标准的轻量级锁。(偏向锁只能在单线程下起作用)
  因此 偏向锁->轻量级锁->重量级锁

  • 优点:通过消除资源无竞争情况下的同步原语,进一步提高了程序的运行性能。
  • 适用场景:始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去执行同步块,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致stop the word操作,撤销偏向所的时候会导致进入安全点,安全点会导致stw,导致性能下降,这种情况下应当禁用。
  • jvm开启/关闭偏向锁:开启:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0;关闭:-XX:-UseBiasedLocking。
轻量级锁

  轻量级锁是偏向锁的升级实现。偏向锁只允许单一线程进入获得锁(有多个线程竞争会撤销偏向锁),轻量级锁允许多个线程获得锁,但是多个线程要按顺序拿锁,不允许竞争(拿锁失败),流程如下:

  • 1. 线程在执行同步代码块之前,JVM会先在当前线程的栈帧中创建一个空间用来存储锁记录,再把对象头中的Mark Word复制到该锁记录中,官方称之为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word 替换为指向锁记录的指针。如果成功,则获得锁,进入步骤3。如果失败执行步骤2。
  • 2. 线程自旋,自旋成功,获得锁,进入步骤3。自旋失败后阻塞变成重量级锁,并把锁标志位变为10,进入步骤3。
  • 3. 锁的持有线程执行同步代码,执行完CAS替换Mark Word成功释放锁,如果CAS成功则流程结束,CAS失败执行步骤4。
  • 4. CAS执行失败说明期间有线程尝试获得锁并自旋失败,轻量级锁升级为了重量级锁,此时释放锁之后,还要唤醒等待的线程。

###锁总结
  synchronized的执行过程:

  • 1.检测Mark Word中是不是当前线程的ID,如果是,表明当前线程持有的锁为偏向锁,如果不是,进入步骤2
  • 2.使用CAS将当前线程的ID替换Mard Word,如果成功表示当前线程获得偏向锁,置偏向标志位1,失败则进入步骤3
  • 3.识别到发生了锁竞争,撤销偏向锁,升级为轻量级锁。
  • 4.当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁。
  • 5.如果失败,说明锁被其他线程持有,尝试使用自旋来获取锁。
  • 6.如果自旋成功获得轻量级锁。
  • 7.如果自旋失败,线程阻塞,升级重量级锁。

  上面几种锁都是JVM自己内部实现,当我们执行synchronized同步块的时候jvm会根据启用的锁和当前线程的争用情况,决定如何执行同步操作;
  注意:锁竞争激烈时,禁用偏向锁。

锁优化

  以上介绍的锁不是代码中能够控制的,但是借鉴上面的思想,可以优化线程的加锁操作。

  • 减少锁的持有时间:不需要同步执行的代码,放在同步代码块外,让锁尽快释放。
  • 减少锁的粒度:它的思想是将物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁竞争。它的思想也是用空间来换时间。注意:最多可以将一个锁拆为当前cpu数量个锁。
  • 锁粗化:大部分情况下我们让锁的粒度最小化,锁的粗化则是增大锁的粒度。例子:有一个循环,循环内的操作需要加锁,应该把锁放到循环外面,否则每次进出循环都进出一次临界区,效率非常差。
  • 使用读写锁:ReentrantReadWriteLock 是一个读写锁,读操作加读锁,可以并发读,写操作使用写锁,只能单线程写。
  • 读写分离:从JDK1.5开始Java提供了CopyOnWriteArrayList 、CopyOnWriteArraySet这两个使用CopyOnWrite机制的容器,CopyOnWrite简称COW,是程序设计的一种优化策略,一开始所有线程共享同一个内容,当某个线程想要修改内容时,会把内容Copy出去形成一个新的内容再对新的修改,修改完成后将原容器的引用指向新的容器,这是一种延时懒惰策略。CopyOnWrite并发容器用于读多写少的并发场景。

final关键字

  Java中的final关键字非常重要,开发中的使用场景相对较多,例如匿名内部类。Java中最常用的String就是一个final 类。Java中final关键字可以作用于变量(成员变量,本地变量)、方法、类,被final修饰的引用只能赋值一次且赋值后不可修改。

final变量(常量)

  final变量指由final关键字修饰的成员变量或本地变量(只能局部使用的变量,例如方法里定义的变量)。final变量也被称为常量,常量名使用英文大写,单词之间用下划线连接的格式(约定优于配置)。例如:

public static final String EXAMPLE = "hello world";

final变量只能赋值一次,赋值后变成只读的常量,不允许修改。

final方法

  final方法指由final关键字修饰的方法,final方法的特点是不可被子类覆写,同时final方法比非final方法性能更优,因为final方法是编译时静态绑定的,运行时不需要动态绑定。如果编码时确定方法已经足够完整或者不希望该方法被子类覆盖,可以使用final关键字修饰方法。

final类

  final类指由final关键字修饰的类,final类的特点是不可被继承,当确定类功能实现完整或者不希望类被继承时,可以使用final修饰类。

final优点

  • 提高性能:jvm会缓存final常量
  • 多线程安全:final变量可以安全的在多线程环境共享,不需要额外的同步开销
  • 使用final关键字,JVM会对方法、变量及类进行优化

final特点

  • final成员变量必须在声明的时候初始化或者在构造器中初始化,否则会编译错误
  • 不能对final变量二次赋值
  • 本地final变量必须在声明时赋值
  • 在匿名类中所有变量都必须是final变量
  • final方法不能被重写
  • final类不能被继承
  • 接口中声明的所有变量都是final变量
  • final和abstract不可以修饰同一对象
  • final方法在编译阶段绑定,称为静态绑定(static binding)
  • 将类、方法、变量声明为final能够提高性能,JVM会进行估计和优化
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值