文章参考 《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是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关键字更轻量级的同步机制。
如果从宏观分类,那么锁类型有两种,分别是乐观锁和悲观锁。
乐观锁,顾名思义,对数据的高并发冲突保持乐观态度,认为写少读多,发生并发写的概率较小,每次读数据的时候认为数据不会被其他线程修改,不对数据上锁,只在提交操作时检查是否违反数据完整性。更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则重复读-比较-写的操作。
悲观锁指对情况发展时刻抱有悲观的思想,认为写多读少,发生并发写的概率较大,每次读数据都认为数据会被其他人修改,所以每次读取数据时都会将数据上锁,其他线程如果读取数据会变成阻塞状态,直到获得被释放的锁。
Java中的悲观锁就是synchronized。在Java中,使用synchronized会导致抢不到锁的线程进入阻塞状态,这是一个重量级的同步操作,所以synchronized也被称为重量级锁。Java为了解决synchronized带来的性能问题,在jdk1.5之后(包含1.5)的版本引入了自旋锁、偏向锁和轻量级锁,重量级锁是悲观锁,自旋锁、偏向锁和轻量锁是乐观锁,不同的锁有不同的特点,对应不同的业务需求。
Java中的重量级锁是synchronized。synchronized可以修饰方法、类等。
Synchronized是非公平锁。先等待锁的线程不一定先获得锁。
jdk1.6启动自旋锁可以通过配置-XX:+UseSpinning启动,-XX:PreBlockSpin=10 为自旋次数,默认为10次,jdk1.7之后由jvm控制启动。
自旋锁的原理是让线程循环观察(自旋)锁的持有者是否已经释放锁,不让自身处于阻塞状态(NON-BLOCKING)。注意:自旋时占用cpu资源,不能永久循环,需要设置最大等待时间,超过最大等待时间后线程停止自旋进入阻塞状态。
Java偏向锁(Biased Locking)是Java6引入的一项多线程优化。它通过消除资源无竞争情况下的同步原语,进一步提高了程序的运行性能。
偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。
如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会尝试消除它身上的偏向锁,将锁恢复到标准的轻量级锁。(偏向锁只能在单线程下起作用)
因此 偏向锁->轻量级锁->重量级锁。
轻量级锁是偏向锁的升级实现。偏向锁只允许单一线程进入获得锁(有多个线程竞争会撤销偏向锁),轻量级锁允许多个线程获得锁,但是多个线程要按顺序拿锁,不允许竞争(拿锁失败),流程如下:
synchronized的执行过程:
上面几种锁都是JVM自己内部实现,当我们执行synchronized同步块的时候jvm会根据启用的锁和当前线程的争用情况,决定如何执行同步操作;
注意:锁竞争激烈时,禁用偏向锁。
以上介绍的锁不是代码中能够控制的,但是借鉴上面的思想,可以优化线程的加锁操作。
Java中的final关键字非常重要,开发中的使用场景相对较多,例如匿名内部类。Java中最常用的String就是一个final 类。Java中final关键字可以作用于变量(成员变量,本地变量)、方法、类,被final修饰的引用只能赋值一次且赋值后不可修改。
final变量指由final关键字修饰的成员变量或本地变量(只能局部使用的变量,例如方法里定义的变量)。final变量也被称为常量,常量名使用英文大写,单词之间用下划线连接的格式(约定优于配置)。例如:
public static final String EXAMPLE = "hello world";
final变量只能赋值一次,赋值后变成只读的常量,不允许修改。
final方法指由final关键字修饰的方法,final方法的特点是不可被子类覆写,同时final方法比非final方法性能更优,因为final方法是编译时静态绑定的,运行时不需要动态绑定。如果编码时确定方法已经足够完整或者不希望该方法被子类覆盖,可以使用final关键字修饰方法。
final类指由final关键字修饰的类,final类的特点是不可被继承,当确定类功能实现完整或者不希望类被继承时,可以使用final修饰类。