要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。
为了直观地了解什么是原子性,我们看下下面哪些操作是原子性操作
int count = 0; // 1
count++; // 2
int a = count; // 3
除了语句1是原子操作,其它两个语句都不是原子性操作,下面我们来分析一下语句2
其实语句2在执行的时候,包含三个指令操作:
对于上面的三条指令来说,如果线程 A 在指令 1 执行完后做线程切换,线程 B 执行完3个指令,又切换回线程 A ,那么我们会发现两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1。这也是经典的缓存一致性问题。这个能被多个线程访问的变量count称为共享变量。
注意:操作系统做任务切换,可以发生在任何一条CPU 指令执行完
为了性能优化,编译器和处理器会进行指令重排序,有时候会改变程序中语句的先后顺序,在多线程并发运行的程序中,这种优化可能会导致执行结果的变化。
例如,在单例模式中,如下:
public class Singleton {
static Singleton instance;
static Singleton getInstance() { // 返回实例
if (instance == null) {
synchronized(Singleton.class) { // 同一时间只能有一个线程持有类Singleton的class对象
if (instance == null)
instance = new Singleton(); // 创建实例
}
}
return instance;
}
}
创建实例的语句 instance = new Singleton()
未被编译器优化的指令执行顺序:
编译器优化后:
假如现在有A、B两个线程,线程 A 先执行 getInstance() 方法,执行完优化后的指令2时,发生了线程切换,线程B开始执行该方法,执行到第一次判断 instance==null 会发现 instance 不等于 null 了,所以直接返回 instance ,而此时的 instance 是没有初始化过的,与预期不符。现行的比较通用的做法就是采用静态内部类的方式来实现单例模式:
public class SingletonDemo {
private SingletonDemo() {
}
private static class SingletonDemoHandler { // 静态内部类
private static SingletonDemo instance = new SingletonDemo(); // 静态成员变量,在这个内部类加载的时候,就被加入到()方法中运行
}
public static SingletonDemo getInstance() {
return SingletonDemoHandler.instance; // 创建内部类实例,此时加载内部类到 JVM
}
}
指的是当一个线程修改了共享变量后,其他线程能够立即得知这个修改。
线程1对共享变量的修改要被线程2及时看到的话,要经过如下步骤:
回到缓存不一致问题,也就是破坏了java语句原子性的问题。如果一个变量在多个CPU(或一个CPU的不同时间段)中都存在缓存,就可能存在缓存不一致的问题。为了解决缓存不一致性问题,通常来说在硬件层面有以下 2 种解决方法:
在早期的 CPU 中,通过在总线上加 LOCK# 锁可以解决缓存不一致问题。因为 CPU 和其他部件进行通信都是通过总线来进行的,如果对总线加 LOCK# 锁的话,也就能够阻塞其他 CPU 对其他计算机部件(如内存)的访问,从而使得同一时刻只能有一个 CPU 使用这个变量的内存数据。比如上面一节的例子中,如果一个线程在执行 count++,在执行这段代码的过程中,总线上发出了 LOCK# 锁的信号,那么只有等待这段代码完全执行完毕之后,其他 CPU 才能再从内存读取变量count的值,然后进行操作。这样就解决了缓存不一致的问题。
但是上面的方式导致用 LOCK# 锁住总线的期间,其他 CPU 无法访问内存,导致线程并发效率低下。
为了保证一定的并发效率,不使用总线加锁的方式,缓存一致性协议出现了。最出名的就是英特尔的 MESI 协议,MESI协议保证了每个缓存中使用的共享变量副本是一致的。它核心的思想是:当 CPU 写数据时,如果发现操作的变量是共享变量,即在其他 CPU 中也可能存在该变量的副本,就会发出信号通知其他 CPU 将该变量的缓存置为无效状态,因此当其他 CPU 需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
此种方法比锁总线的方法效率更高,当锁总线的方法更加通用。缓存一致性协议也是volatile的一部分底层实现保证。
即前一节可见性中介绍的内存模型,它是在软件侧面解决 java 并发问题的基础,它能够屏蔽各个硬件平台和操作系统的内存访问差异,实现让 java 程序在各种平台下都能达到一致的内存访问效果。
java 内存模型定义了程序中变量的访问规则,往大一点说,定义了程序执行的次序。注意,为了获得较好的执行性能, Java 内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。也就是说,在 java 内存模型中,也会存在缓存一致性问题和指令重排序的问题。所以,Java内存模型的设计并没有直接解决三性问题,而是提供了解决的基础。
在java中,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
注意:在 32 位平台下,对 64 位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性。但是好像在最新的 JDK 中,JVM 已经保证对 64 位数据的读取和赋值也是原子性操作了。
java 内存模型只保证了基本读取和常量赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过 synchronized、Lock或CAS机制来实现。
对于可见性,Java提供了 volatile 关键字来保证。
当一个共享变量被 volatile 修饰时,它的值一旦被修改,就会被立即更新到主存,当其他线程要读取该值时,会直接去内存中读取新值,而不是在自己的私有工作空间读取旧值。未被volatile修饰的共享变量不能保证可见性,因为被修改之后,最新的值什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值。
另外,synchronized 和 Lock 也能够保证可见性,因为synchronized 和 Lock 能保证同一时刻只有一个线程获取锁,然后执行同步代码,所以不存在共享变量,也就不会有别的线程来修改已经被 synchronized 和 Lock 锁定的变量。在释放锁之前会将对变量的修改刷新到主存当中。
Volatile能够禁止指令重排序,但synchronized 和 Lock不会禁止指令的重排序,因为它们将线程编程了串行执行的,即时重排序也不会影响串行执行线程的执行结果,所以也就不禁止了。
另外,Java 内存模型具备一些先天的有序性,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从 happens-before 原则推导出来,那么就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
happens-before原则(先行发生原则):
start()
方法先行发生于此线程内的每个动作interrupt()
方法的调用先行发生于被中断线程的代码检测到中断事件的发生Thread.join()
方法结束、Thread.isAlive()
的返回值手段检测到线程已经终止执行finalize()
方法的开始前 4 条规则是比较重要的,后 4 条规则都是显而易见的,总结下来就是:
此部分内容来自马士兵教育在 b 站的 jvm 公开课,此部分和锁的实现较为相关
java对象的内存布局,即java对象在堆中存储时的内存布局。在maven中引入工具JOL(java object layout)后,可以使用System.out.println(ClassLayout.parseInstance(new Object()).toPrintable();
输出java对象的内存布局:
new一个对象,此对象的内存布局如下:
每个对象都拥有锁,都可以被独占,对象头中的 markword 是用来保存锁状态的,非常重要,会在锁升级部分中讲解markword具体保存什么数据
1、markword:8字节,记录锁这个对象的信息
2、class pointer:指向该对象的Class对象,markword和class pointer合起来就是对象头。默认情况下,JVM开启了指针压缩,此部分占4字节,若不开启指针压缩,此部分在64位机器上就是8字节。通过jvm参数-XX:+UseCompressedClassPointers可以设置。另一个参数叫做-XX:+UseCompressedOops意为普通对象指针压缩,如指向String常量的String类型引用变量就是这种指针,被压缩为4字节
3、instance data:成员变量的引用或基础变量直接存储
4、padding:使得整个对象占有的内存达到8字节的倍数,因为总线读内存的时候,按照8字节的倍数读,如果分开存储就会变慢,所以padding是用来提高效率用的
此关键字用来锁定一个对象。纠正一个错误:代码是不会被锁定的,只能锁定对象
Object o = new Object();
synchronized(o){
// 执行这段代码的时候,对象o被此线程独占
}
synchronized void methodName() {
// 此方法执行过程中,this对象被锁定
}
static synchronized void methodName() {
// 此方法执行过程中,this对象的class对象被锁定
// 相当于写成:
// synchronized(Main.class)
}
具体实现过程:
1、代码层面:synchronized
2、字节码层面:在互斥代码区前面加 monitor enter ,在互斥代码区最后加 monitor exit
3、JVM层面:进行锁升级
4、汇编指令:lock cmpxchg
,利用计算机系统的mutex Lock实现。每一个可重入锁都会关联一个线程ID和一个锁状态status,所以很好做重入操作的判断。
JDK1.6 为了提升性能,减少获得锁和释放锁所带来的消耗,引入了4种锁的状态:无锁(new)、偏向锁、轻量级锁(自旋锁、无锁CAS)和重量级锁,它会随着多线程竞争变得激励而逐渐升级。锁降级是gc中的一个过程,就是没有任何线程访问这个对象,只有gc线程访问它,所以说降级没有任何意义,所以可以认为锁降级不存在。
在 Java 中,synchronized 关键字内部实现原理就是锁升级的过程:无锁 --> 偏向锁 --> 轻量级锁 --> 重量级锁。synchronized默认是一个非公平锁,所有挂起的线程会被放在等待队列中,这个队列默认是无序的。在hotspot中,不同的锁状态对应markword为:
图源:马士兵教育在b站的jvm公开课视频
在实际运行中,要获得锁状态,会首先检查最右两位“锁标志位”,然后检查偏向锁位,即可分出5种状态来。
上图中的分代年龄即gc回收需要使用的对象年龄记录。ps/po中的默认值为15,CMS中的默认值为6,分代年龄用4位表示,最大就是15,不能再更大了。
一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰之后,那么就具备了两层语义:
先看一段代码,假如线程 1 先执行,线程 2 后执行:
//线程1
boolean stop = false;
while(!stop){
doSomething();
}
//线程2
stop = true;
这是一段很典型的代码,很多人在中断线程时可能都会采用这种标记办法。但是事实上,线程1不一定会被成功中断。在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。
因为线程1和2在运行的时候,会将 stop 变量的值拷贝一份放在自己的工作内存当中。当线程 2 更改了 stop 变量的值之后,有可能在还没来得及写入主存中的时候,就发生了线程切换,线程 2 被迫阻塞,而线程1不知道线程 2 对 stop 变量的更改,会一直循环下去(假设是多核CPU)。
但是用 volatile 修饰之后,在工作内存中被修改的值会立即写入主存,且当线程 2 修改stop时,线程 1 的工作内存中缓存变量 stop 的缓存行被置为无效状态,线程 1 只能再次从内存读取变量 stop 的值,才能接着使用。
看例子:
public class Main {
volatile int inc = 0;
void increase() {
inc++;
}
public static void main(String[] args) {
Main main = new Main();
for (int i = 0; i < 10; i++) {
new Thread(){
public void run() {
for (int j = 0; j < 1000; j++) {
main.increase();
}
}
}.start();
}
while(Thread.activeCount()>1){ // 保证前面的进程都执行完了
Thread.yield();
}
System.out.println(main.inc);
}
}
多次运行发现,上面的运行结果都不一样,都小于我们的预期10000
因为inc++这个语句包括3个指令:1)读入工作内存;2)+1;3)写入内存。
假如线程1刚刚把inc读入工作内存,就被阻塞,然后线程2开始执行inc++,由于线程1尚未进行+1,也就不会导致线程2的inc对应缓存行无效,所以线程2会直接执行+1指令,然后写入内存,这时线程1回来了,继续执行+1和写入内存,结果就是内存中的inc只+1,而不是+2
注意:Volatile的起效时刻是线程读入工作内存的时刻,在这一时刻,线程会判断inc是否已经在别的线程的工作内存被改变了,但我们的例子中,线程1读完inc以后,inc才被线程2改变,所以就出现错误啦
改的方法有3个(前两个是直接用重量级锁,最后一个是使用无锁的CAS):
1.用synchronized
public class Main {
int inc = 0; // 变量不用管,因为方法是同步的,一次必然只有一个线程操作inc
synchronized void increase() { // 方法改成同步的
inc++;
}
public static void main(String[] args) {
Main main = new Main();
for (int i = 0; i < 10; i++) {
new Thread(){
public void run() {
for (int j = 0; j < 1000; j++) {
main.increase();
}
}
}.start();
}
while(Thread.activeCount()>1){ // 保证前面的进程都执行完了
Thread.yield();
}
System.out.println(main.inc);
}
}
2.使用Reentrant锁
public class Main {
int inc = 0;
Lock lock = new ReentrantLock(); // 使用Reentrant锁
void increase() {
lock.lock(); // 锁定
try {
inc++;
} finally {
lock.unlock(); // 保证锁一定被解开
}
}
public static void main(String[] args) {
Main main = new Main();
for (int i = 0; i < 10; i++) {
new Thread() {
public void run() {
for (int j = 0; j < 1000; j++) {
main.increase();
}
}
}.start();
}
while (Thread.activeCount() > 1) { // 保证前面的进程都执行完了
Thread.yield();
}
System.out.println(main.inc);
}
}
3.使用AtomicInteger
import java.util.concurrent.atomic;
public class Main {
AtomicInteger inc = new AtomicInteger(); // 一种保证自增原子性的类
void increase() {
inc.getAndIncrement();
}
public static void main(String[] args) {
Main main = new Main();
for (int i = 0; i < 10; i++) {
new Thread() {
public void run() {
for (int j = 0; j < 1000; j++) {
main.increase();
}
}
}.start();
}
while (Thread.activeCount() > 1) { // 保证前面的进程都执行完了
Thread.yield();
}
System.out.println(main.inc);
}
}
java 1.5的 java.util.concurrent.atomic 包下提供了一些原子操作类,即对基本数据类型的自增、自减、以及加法操作、减法操作进行了封装,保证这些操作是原子性操作。atomic 包是利用 CAS 来实现原子性操作的(Compare And Swap),CAS 实际上是利用处理器提供的 CMPXCHG 指令实现的,而处理器执行 CMPXCHG 指令是一个原子性操作。
编译器的优化行为——重排序,会导致指令乱序执行。比如2条指令:1、去内存读取数据;2、与第一条指令无关。指令1必然较慢,编译器会使得cpu等待指令1结束的时间内,首先执行完成指令2。此即发生了乱序执行,也叫流水线执行,多线程并发环境下会出现一些问题。
volatile会禁止重排序,但并不是真的不让编译器优化,而是只可以优化部分指令顺序。
举个例子:
//x、y为非volatile变量
//flag为volatile变量
x = 2; //语句1
y = 0; //语句2
flag = true; //语句3
x = 4; //语句4
y = -1; //语句5
由于 flag 变量为 volatile 变量,那么重排序的时候,就不会将3放到1和2前面,也不会将3放到4和5后面。但是要注意1和2之间的顺序、4和5之间的顺序是不作任何保证的。并且 volatile 关键字能保证,执行到 3 时,1 和 2 必定是执行完毕了的,且1和2的执行结果对345都是可见的。
怎么实现的呢?
答:当我们在源码中的某变量前加了volatile修饰,字节码中就会多一条字节码ACC_VOLATILE,JVM执行这条字节码的时候,就会添加不同的内存屏障。可以把内存屏障看成一堵墙,编译器优化指令顺序时,不允许将屏障两边的指令顺序搞乱。jvm规范(JSR)中要求jvm的实现必须提供以下类型的内存屏障:LoadLoad屏障、StoreStore屏障、LoadStore屏障、StoreLoad屏障。例如LoadLoad屏障前后的2条读指令不可重排序,其他屏障同理。
再详细一点看,在volatile变量的写操作之前,需要加SS屏障,后面要加SL屏障,为什么呢?SS屏障前面的读操作和其后面的读操作不能换顺序,白话就是:必须写完了,才能写。同理,SL屏障前面的写操作和后面的读操作不能换顺序,白话就是:必须写完了,才能读。
在volatile变量的读操作之前,需要加LL屏障,后面要加LS屏障。
所以说,内存屏障其实是jvm来加的,至于怎么实现,去看c++源码吧,其实跟系统底层的fence等原语无关,而是用的lock指令,把总线锁了。为啥不用cpu支持的原语,非要自己实现呢?因为不是所有cpu的这些原语都支持一致性,但lock指令是几乎所有cpu都支持的,所以jvm偷懒用了这条指令。
cpu和主存之间的缓存是分级的,它们之间读取是以chache line为基本单位的,一个cache line大小为64字节,也可称为按块读取。按块读取的设计主要是考虑了磁盘上数据的局部性质,例如我们需要读取x的值,那么与它相邻的y通常就是下一个要读取的变量。下图就是缓存的一个示意:
L3由多个核共享。现在假设一种场景:
线程1主要利用cpu1修改上图中的x,线程2主要利用cpu2修改上图中的y,已知x和y在一个cache line中,并且它们都被volatile关键字所修饰。那么每次x或y的值被修改,都会通知另一个线程x或y的值发生变化,下次要使用此变量的值,请重新去主存中读取,另一个线程就会读取相应的缓存行到自己的缓存中去。因为在cpu层面的数据一致性保证是以cache line为单位的,所以每次都会读整个缓存行,而不是单独的一个x或y,虽然可能只有x或y被volatile修饰。此即缓存一致性协议,英特尔的cpu用的是MESI协议,别的就不一定了。MESI缓存一致性协议对缓存行进行状态标记:modified、exclusive、shared、invalid。当某些数据过大,一个缓存行无法存下,就会只用锁总线的方式进行缓存一致性的实现(锁总线是一个实现缓存一致性的万能方式)。
代码实现以上场景:
public class T1 {
static class T {
public volatile long x = 0L; // 8字节
}
static T[] arr = new T[2];
static {
arr[0] = new T();
arr[1] = new T();
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (long i = 0; i < 1000_0000L; i++) {
arr[0].x = i;
}
});
Thread t2 = new Thread(() -> {
for (long i = 0; i < 1000_0000L; i++) {
arr[1].x = i;
}
});
final long start = System.nanoTime();
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println((System.nanoTime() - start) / 100_0000);
}
}
上面的代码中,arr[0]和arr[1]大概率会在同一个缓存块中,每次修改arr[0]或arr[1]中的x都会通知另一个线程重新读取主存中的缓存行,运行结果为200多毫秒。如果做如下修改:
public class T2 {
static class Padding {
public volatile long p1, p2, p3, p4, p5, p6, p7;
}
static class T extends Padding {
public volatile long x = 0L;
}
static T[] arr = new T[2];
static {
arr[0] = new T();
arr[1] = new T();
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (long i = 0; i < 1000_0000L; i++) {
arr[0].x = i;
}
});
Thread t2 = new Thread(() -> {
for (long i = 0; i < 1000_0000L; i++) {
arr[1].x = i;
}
});
final long start = System.nanoTime();
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println((System.nanoTime() - start) / 100_0000);
}
}
上面的代码将两个x弄进了两个缓存行内,运行结果为90毫秒左右,提升效率,这就叫做缓存行对齐技术。消息队列框架Disruptor就采用了这种技术,部分源码:
public long p1, p2, p3, p4, p5, p6, p7; // padding
private volatile long cursor = INITIAL_CURSOR_VALUE; // 关键
public long p8, p9, p10, p11, p12, p13, p14; // padding
关键值cursor的前后都加了padding,使得cursor不会和其他变量进入同一缓存行内。
超线程:如果一个核内有多组寄存器(指令寄存器+数据寄存器),那么不同的线程就可以在一个核内同时运行,所谓的四核八线程、二核十六线程就是这个概念。这样的设计是因为逻辑计算单元ALU速度太快了,多套寄存器可以更加充分地利用ALU的算力。
CAS(Compare And Swap),即比较并交换。使用Synchronized和Lock类,可以实现原子性,但它们是悲观锁,有时会导致频繁的上下文切换,消耗太大。CAS算法可以实现无锁的原子性保证,同时消耗不像上述重量级锁那么大。
CAS 操作包含三个操作数——内存位置V、预期原值A和新值B。Volatile修饰的数据原值A被线程从主存拿到后,计算得到最新的值B,比较 A 和主存当前存储的值V,若 V 与 A 相等,则更新B到主存中,此变量值修改成功;若不相等,说明这个原值被别的线程改了,修改就失败了,之后就会一直循环重试,直到V与A相等。
一个变量 count 被线程1修改为值 count1,后来又被线程2修改回值 count,此时CAS操作就会成功,但是此时的 count 已经不是之前的 count 了。解决这个问题的方法是:给每个值前面加一个版本号,每次更新值的同时也会更新版本号,就可以知道此时的count 是不是未被修改过的 count 。
从 Java 1.5 开始 JDK 的atomic包里提供了一个类 AtomicStampedReference 来解决 ABA 问题。这个类的 compareAndSet 方法会首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的新值。
例如线程1检查V==A完毕后,尚未将V赋值为B,另一个线程2过来将V改成了C,然后又切换回线程1,线程1不会再检查V是否等于A,便直接赋值为B,就出错啦!所以要保证CAS在多线程并发时的正确性,就必须保证它的原子性。在Unsafe的各种本地方法中,在jvm层面c++实现了汇编级的原子性。指令就是:lock cmpxchg
利用CAS可以实现自旋锁,自旋锁就是请求锁不成功时,线程会一直循环地等待,直到锁释放。这种无锁方式,适用于线程较少、任务执行快且频繁的场景下,因为如果执行太慢、更新不频繁,自旋的设计会导致CPU一直在那个自旋的循环内空转,浪费资源。相反,线程数少、执行时间比较长时,用重量级锁synchronized。
在 Java 中,sun.misc.Unsafe类提供了硬件级别的原子操作来实现这个 CAS,java.util.concurrent包下的大量类都使用了这个Unsafe类的 CAS 操作。
java.util.concurrent.atomic包下的类大多是使用 CAS 操作来实现原子性操作的,如AtomicInteger、AtomicBoolean和AtomicLong等。下面以AtomicInteger的部分实现来大致讲解下这些原子类的实现。
public class AtomicInteger extends Number implements java.io.Serializable {
private static final long serialVersionUID = 6214790243416807050L;
// 为了使用Unsafe类中的CAS机制,先get一个UnSafe对象
private static final Unsafe unsafe = Unsafe.getUnsafe();
private volatile int value;// 初始数值
// 省略了部分代码...
// 有参构造函数,可设置初始数值
public AtomicInteger(int initialValue) {
value = initialValue;
}
// 无参构造函数,初始数值为0
public AtomicInteger() {
}
// 获取当前值
public final int get() {
return value;
}
// 设置新值
public final void set(int newValue) {
value = newValue;
}
//返回原值并设置新值
public final int getAndSet(int newValue) {
// 使用for循环不断通过CAS操作来设置新值
for (;;) {
int current = get();
if (compareAndSet(current, newValue))
return current;
}
}
// 利用unSafe的方法,原子地设置新值为update, expect为期望的原值
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update); // native方法
}
// 获取当前值current,并使current+1
public final int getAndIncrement() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next)) // 自增
return current;
}
}
// 此处省略部分代码,余下的代码大致实现原理都是类似的
}
一般来说,在竞争不是特别激烈的时候,使用该包下的原子操作性能比使用synchronized关键字的方式高效的多。可以从getAndSet()方法看出,如果资源竞争十分激烈的话,这个for循环可能换持续很久都不能成功跳出。在这种情况下,我们可能需要考虑如何降低对资源的竞争。
以上的原子类地一个典型应用就是计数,在多线程的情况下需要考虑线程安全问题,示例代码如下:
public class Counter {
private int count;
public Counter(){}
public int getCount(){
return count;
}
public void increase(){
count++;
}
}
上面这个类在多线程环境下会有线程安全问题,要解决这个问题最简单的方式可能就是加锁,优化代码如下:
public class Counter {
private int count;
public Counter(){}
public synchronized int getCount(){
return count;
}
public synchronized void increase(){
count++;
}
}
使用原子类提供的乐观锁实现,大多竞争没那么激烈的情况下,效率更高:
public class Counter {
private AtomicInteger count = new AtomicInteger();
public Counter(){}
public int getCount(){
return count.get();
}
public void increase(){
count.getAndIncrement();
}
}
在另一篇文章里:ReentrantLock源码解析
悲观锁
一个共享数据加了悲观锁,那线程每次想操作这个数据前都会假设其他线程也会操作这个数据,所以每次操作前都会上锁,这样其他线程想操作这个数据拿不到锁只能阻塞了。
在 Java 语言中 synchronized 和 ReentrantLock等就是典型的悲观锁,还有一些使用了 synchronized 关键字的容器类如 HashTable 等也是悲观锁的应用。
适用于读少写多场景,写的时候不能让其他线程参与进来,使用乐观锁会导致线程不断进行重试,这样可能还降低了性能
乐观锁
乐观锁操作数据时不会上锁,在更新的时候会判断一下在此期间是否有其他线程去更新这个数据。乐观锁可以使用版本号机制和CAS算法实现。在 Java 语言中 java.util.concurrent.atomic包下的原子类就是使用CAS 乐观锁实现的。
适用于写少读多场景,尽量减少阻塞,节省开销
独占锁
独占锁是指锁一次只能被一个线程所持有。如果一个线程对数据加上排他锁后,那么其他线程不能再对该数据加任何类型的锁。获得独占锁的线程即能读数据又能修改数据。
共享锁
共享锁是指锁可被多个线程所持有。如果一个线程对数据加上共享锁后,那么其他线程只能对数据再加共享锁,不能加独占锁。获得共享锁的线程只能读数据,不能修改数据。
互斥锁
互斥锁是独占锁的一种常规实现,是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。
读写锁
读写锁是共享锁的一种具体实现。读写锁管理一个只读的锁,和一个写锁。
读锁可以在没有写锁的时候被多个线程同时持有,是共享锁,写锁则是独占锁。写锁的优先级要高于读锁,一个获得了读锁的线程必须能看到前一个释放的写锁所更新的内容(及时更新)。
读写锁相比于互斥锁并发程度更高,每次只有一个写线程,但是同时可以有多个线程并发读。
JDK中有一个读写锁接口
public interface ReadWriteLock {
// 获取读锁
Lock readLock();
// 获取写锁
Lock writeLock();
}
ReentrantReadWriteLock实现了这个接口。
公平锁
多个线程按照申请锁的顺序来获取锁。
非公平锁
不按照申请锁的顺序分配锁,有可能后申请的线程比先申请的线程优先获取锁,在高并发环境下,有可能造成优先级翻转,或者饥饿的状态(某个线程一直得不到锁)。
在 java 中 synchronized 关键字是非公平锁,ReentrantLock默认也是非公平锁。通过传入boolean值给构造函数,可以创建公平的ReentrantLock
// 创建一个公平锁,true 表示公平锁,false 表示非公平锁。默认非公平锁
Lock lock = new ReentrantLock(true);
又称之为递归锁,是指同一个线程在外层方法获取了锁,在进入内层方法会自动获取锁。从ReentrantLock的名字就可以看出它是一个可重入锁。Synchronized也是一个可重入锁。可重入锁的一个好处是可一定程度避免死锁。以 synchronized 为例,看一下下面的代码:
public synchronized void mehtodA() throws Exception{
// Do some magic tings
mehtodB();
}
public synchronized void mehtodB() throws Exception{
// Do some magic tings
}
methodA 调用 methodB,如果一个线程调用methodA时,已经获取了锁,再去调用 methodB 时,就不需要再次获取锁了,这就是可重入锁的特性。如果不是可重入锁的话,mehtodB 可能不会被当前线程执行,可能造成死锁。
与synchronized的区别:
synchronized基于JVM实现,ReentrantLock在JDK1.6时加入,可查看源码。两者都是可重入锁。ReentrantLock需要手动声明、加锁、释放锁,为了避免忘记释放锁,需要将释放的代码放入finally块中。ReentrantLock的功能更高级一些,当竞争激烈时,使用ReentrantLock更好。
指线程在没有获得锁时不是被直接挂起,而是执行一个忙循环,这个忙循环就是所谓的自旋,当锁被其他进程占用的时间较短时,这个线程就不会被挂起,而是在一段时间的忙循环以后,直接获得锁。
自旋锁的目的是为了减少线程被挂起的几率,因为线程的挂起和唤醒也都是耗资源的操作。如果锁被另一个线程占用的时间比较长,即使自旋了之后当前线程还是会被挂起,忙循环就会变成浪费系统资源的操作,反而降低了整体性能。因此自旋锁是不适应锁占用时间长的情况。
AtomicInteger类有自旋的操作:
public final int getAndAddInt(Object o, long offset, int delta {
int v;
do {
v = getIntVolatile(o, offset);
} while (!compareAndSwapInt(o, offset, v, v + delta));
return v;
}
CAS 操作如果失败就会一直循环获取当前 value 值然后重试。
在JDK1.6又引入了自适应自旋锁,自旋时间不再固定,由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定。如果虚拟机认为这次自旋也很有可能再次成功那就会持续较多的时间,如果自旋很少成功,那以后可能就直接省略掉自旋过程,避免浪费处理器资源。
一种锁的设计,并不是具体的一种锁。分段锁设计目的是将锁的粒度进一步细化,例如当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。CurrentHashMap 底层就用了分段锁,在保证隔离性的同时,可以进行高效的并发使用。
锁粗化就是将多个同步块的数量减少,并将单个同步块的作用范围扩大,本质上就是将多次上锁、解锁的请求合并为一次同步请求,只需加锁和解锁一次即可。
举个例子,一个循环体中有一个同步代码块,每次循环都会执行加锁解锁操作。
private static final Object LOCK = new Object();
for(int i = 0;i < 100; i++) {
synchronized(LOCK){
// do some magic things
}
}
经过锁粗化后就变成下面这个样子了,只需一次上锁:
synchronized(LOCK){
for(int i = 0;i < 100; i++) {
// do some magic things
}
}
锁消除是指虚拟机在运行时检测到了共享数据没有竞争的锁,从而将这些锁进行消除。举个例子:
public String test(String s1, String s2){
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append(s1).append(s2);
return stringBuffer.toString();
}
上面代码中有一个 test 方法,主要作用是将字符串 s1 和字符串 s2 串联起来。test 方法中三个变量s1, s2, stringBuffer,它们都是局部变量,局部变量是在栈上的,栈是线程私有的,所以就算有多个线程访问 test 方法也是线程安全的。
我们都知道StringBuffer 是线程安全的类,append 方法是同步方法,但是 test 方法本来就是线程安全的,为了提升效率,虚拟机帮我们消除了这些同步锁,这个过程就被称为锁消除。
StringBuffer.class
// append 是同步方法
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
如果一个类被设计为允许多线程正确访问,我们就说这个类就是“线程安全”的(thread-safe)。
1、Java标准库的java.lang.StringBuffer是线程安全的。
2、还有一些不变类,例如String,Integer,LocalDate,它们的所有成员变量都是final,多线程同时访问时只能读不能写,这些不变类也是线程安全的。
3、还有,类似Math这些只提供静态方法,没有成员变量的类,也是线程安全的。
4、Vector、HashTable、Properties是线程安全的;ArrayList、LinkedList、HashSet、TreeSet、HashMap、TreeMap等都是线程不安全的。
5、Collection接口提供了几种方法,可以返回指定数组类型的线程安全类型对象,例如static
可以返回一个线程安全的List对象,static
返回一个线程安全的Set对象