Java线程安全以及线程安全的实现方式和内存模型(JMM)
一、了解几个概念
1)临界区:
临界区指的是一个访问共用资源(例如:共用设备或是共用存储器)的程序片段,而这些共用资源又无法同时被多个线程访问的特性。当有线程进入临界区段时,其他线程或是进程必须等待,有一些同步的机制必须在临界区段的进入点与离开点实现,以确保这些共用资源是被互斥获得使用
2)互斥量:
互斥量是一个可以处于两态之一的变量:解锁和加锁。这样,只需要一个二进制位表示它,不过实际上,常常使用一个整型量,0表示解锁,而其他所有的值则表示加锁。互斥量使用两个过程。当一个线程(或进程)需要访问临界区时,它调用mutex_lock。如果该互斥量当前是解锁的(即临界区可用),此调用成功,调用线程可以自由进入该临界区。
另一方面,如果该互斥量已经加锁,调用线程被阻塞,直到在临界区中的线程完成并调用mutex_unlock。如果多个线程被阻塞在该互斥量上,将随机选择一个线程并允许它获得锁。
3)信号量:
信号量(Semaphore),有时被称为信号灯,是在多线程环境下使用的一种设施,是可以用来保证两个或多个关键代码段不被并发调用。在进入一个关键代码段之前,线程必须获取一个信号量;一旦该关键代码段完成了,那么该线程必须释放信号量。其它想进入该关键代码段的线程必须等待直到第一个线程释放信号量。为了完成这个过程,需要创建一个信号量VI,然后将Acquire Semaphore VI以及Release Semaphore VI分别放置在每个关键代码段的首末端。确认这些信号量VI引用的是初始创建的信号量。
4)CAS操作(Compare-and-Swap):
CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B
5)重排序:
编译器和处理器”为了提高性能,而在程序执行时会对程序进行的重排序。它的出现是为了提高程序的并发度,从而提高性能!但是对于多线程程序,重排序可能会导致程序执行的结果不是我们需要的结果!重排序分为“编译器”和“处理器”两个方面,而“处理器”重排序又包括“指令级重排序”和“内存的重排序”
二、Java内存模型(JMM)
线程与内存交互操作如下
所有的变量(实例字段,静态字段,构成数组对象的 元素,不包括局部变量和方法参数)都存储在主内存中,每个线程有自己的工作内存,线程的工作内存保存被线程使用到变量的主内存副本拷贝。线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存的变量。不同线程之间也不能直接访问对方工作内存中的变量,线程间变量值的传递通过主内存来完成。
1、Java内存模型定义了八种操作:
lock(锁定):作用于主内存的变量,它把一个变量标识为一个线程独占的状态;
unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定;
read(读取):作用于主内存的变量,它把一个变量的值从主内存传送到线程中的工作内存,以便随后的load动作使用;
load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中;
use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎;
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存中的变量;
store(存储):作用于工作内存的变量,它把工作内存中的一个变量的值传送到主内存中,以便随后的write操作;
write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值写入主内存的变量中。
2、volatile关键字作用:
1)、当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新到主内存中。
2)、当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量。
3)、禁止指令重排序优化。
4)、volatile关键字不能保证在多线程环境下对共享数据的操作的正确性。可以使用在自己状态改变之后需要立即通知所有线程的情况下,也就是说volatile不能保证线程同步,Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程,这就是所谓的线程可见性,当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值,所以volatile具有可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入,然后synchronized也是具有可见性。
5)、volatile的原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性,所以volatile能保证可见性,不能保证原子性。
理解volatile特性的一个好方法是:把对volatile变量的单个读/写,看成是使用同一个监视器锁对这些单个读/写操作做了同步
class VolatileFeaturesExample {
volatile long vl = 0L; //使用volatile声明64位的long型变量
public void set(long l) {
vl = l; //单个volatile变量的写
}
public void getAndIncrement () {
vl++; //复合(多个)volatile变量的读/写
}
public long get() {
return vl; //单个volatile变量的读
}
}
假设有多个线程分别调用上面程序的三个方法,这个程序在语意上和下面程序等价:
class VolatileFeaturesExample {
long vl = 0L; // 64位的long型普通变量
public synchronized void set(long l) { //对单个的普通 变量的写用同一个监视器同步
vl = l;
}
public void getAndIncrement () { //普通方法调用
long temp = get(); //调用已同步的读方法
temp += 1L; //普通写操作
set(temp); //调用已同步的写方法
}
public synchronized long get() {
//对单个的普通变量的读用同一个监视器同步
return vl;
}
}
3 volatile和synchronized区别
1)、volatile仅能使用在变量级别;synchronized则可以使用在变量、方法和类级别;
2)、volatile读数据直接从主存中读取;synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
3)、volatile能保证可见性,不能保证原子性;而synchronized则可以保证可见性和原子性;
4)、volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
5)、volatile标记的变量不会被编译器优化进行指令重排列;synchronized标记的变量可以被编译器优化进行指令重排列。
三、JMM特性
1)、原子性
就是指该操作是不可再分的。不论是多核还是单核,具有原子性的量,同一时刻只能有一个线程来对它进行操作。简而言之,在整个操作过程中不会被线程调度器中断的操作,都可认为是原子性。比如 a = 1;
2)、可见性
可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改
3)、 有序性
Java内存模型中有序性可归纳为这样一句话:如果在本线程内观察,所有操作都是有序的,如果在一个线程中观察另一个线程,所有操作都是无序的
是指对于单线程的执行代码,执行是按顺序依次进行的。但在多线程环境中,则可能出现乱序现象,因为在编译过程会出现“指令重排”,重排后的指令与原指令的顺序未必一致
四、java中的线程安全等级
不可变:
可以是基本类型的final;可以是final对象,但对象的行为不会对其状态产生任何影响,比如String的subString就是new一个String对象各种Number类型如BigInteger和BigDecimal等大数据类型都是不可变的,但是同为Number子类型的AtomicInteger和AtomicLong则并非不可变。原因与它里面状态对象是unsafe对象有关,所做的操作都是CAS操作,可以保证原子性。
绝对线程安全:
不管运行时环境如何,调用者都不需要任何额外的同步措施。
相对线程安全:
这是我们通常意义上的线程安全。需要保证对象单独的操作是线程安全的。比如Vector,HashTable,synchronizedCollection包装集合等。
线程兼容:
对象本身不是线程安全的,但可以通过同步手段实现。一般我们说的不是线程安全的,绝大多数是指这个。比如ArrayList,HashMap等。
线程对立:
不管调用端是否采用了同步的措施,都无法在并发中使用的代码。
五、线程安全的实现方式
要实现线程安全一般至少需要两个特性:原子性和可见性
1)使用synchronize:它本具有原子性和可见性的,所以如果使用了synchronize修饰的操作,那么就自带了可见性,synchronized使用悲观锁来实现线程安全
2)使用原子类代替基本数据类型,原子类是使用乐观锁来实现线程安全,多线程环境下执行a++,可以使用AtomicInteger类incrementAndGet()方法实现,同样是使用了volatile来保证可见性;使用Unsafe调用native本地方法CAS,CAS采用总线加锁或缓存加锁方式来保证原子性。
3 ) 使用volatile关键字,volatile不一定就有原子性,比如用volatile修饰的变量进行++或者--操作(num++),我们需要让volatile修饰的变量需要具有原子性,那么我们一般可以设置在boolean类型变量上,如下
volatile boolean tag = true;
线程1 while(tag){};
线程2 while(tag){};
如果有变量自增或者自减,我们可以使用原子类(AtomicInteger)
4)使用ThreadLocal对各个线程进行隔离
可以参考我的这篇博客 :Java之ThreaLocal
5)我们还可以用其他的锁,比如重入锁(ReentrantLock) 保证线程安全
6)我们还可以用 临界区、互斥量、信号量 保证线程安全
参考文献:
https://developer.51cto.com/art/201910/605093.htm
https://www.open-open.com/lib/view/open1459412319988.html
https://www.iteye.com/blog/smallbug-vip-2275743
https://juejin.im/post/5c936018f265da60ec281bcb
https://blog.csdn.net/jingzi123456789/article/details/78004074