线程的安全、volatile、synchronized
目录
可见性、原子性、有序性
线程安全
原子性
可见性
有序性
Java内存模型
原子性
可见性
有序性
synchronized
synchronized简单介绍
synchronized的使用
修饰方法
修饰一个代码块
修饰一个静态方法
修饰一个类
synchronized的底层实现
监视器(Monitor)
synchronized 锁的升级顺序
volatile关键字
Java 内存模型
JMM 的规定:
JMM 的抽象示意图:
加锁
使用 volatile 关键字
总线嗅探机制
可见性问题小结
volatile 的原子性问题
禁止指令重排序
内存屏障指令
final域
概述
final修饰类:太监类,没有儿子
final修饰方法
final修饰局部变量
final修饰成员变量
Happens-Before原则
happens-before 定义
原子类Atomic
什么是原子类?用处是什么?如何使用?
为什么Atomic类可以保证原子性?
ThreadLocal的实现原理
什么是 ThreadLocal?
ThreadLocal 内存泄漏
ThreadLocal 应用场景
可见性、原子性、有序性
线程安全
多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的。
原子性
原子性是指一个事物的操作是不可分割的,要么都发生,要么都不发生。
举个例子:
张三到银行给李四转账1000元,张三卡里原来有2000元,李四卡里原来也有两千元,那么转账的步骤应该如下:
如果张三的钱扣完,银行系统瘫痪了,怎么办呢?张三的1000块钱会被会没呢,当然不会。这时候你的钱会退回来。也就是说银行的转账业务要么成功张三(1000元)李四(3000元),要么不发生张三(2000元)李四(2000元)。
同样地反映到并发编程中会出现什么结果呢?
假若一个线程执行到这个语句时,我暂且假设为一个32位的变量赋值包括两个过程:为低16位赋值,为高16位赋值。
那么就可能发生一种情况:当将低16位数值写入之后,突然被中断,而此时又有一个线程去读取i的值,那么读取到的就是错误的数据。
可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
举个简单的例子,看下面这段代码:
//线程1执行的代码
int i = 0;
i = 10;
//线程2执行的代码
j = i;
假若执行线程1的是CPU1
,执行线程2的是CPU2
。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1
的高速缓存中,然后赋值为10,那么在CPU1
的高速缓存当中i的值变为10了,却没有立即写入到主存当中。
此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2
的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10.
这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。
有序性
有序性:即程序执行的顺序按照代码的先后顺序执行。
举个简单的例子,看下面这段代码:
int i = 0;
boolean flag = false;
i = 1; //语句1
flag = true; //语句2
上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。
下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。
但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子:
int a = 10; //语句1
int r = 2; //语句2
a = a + 3; //语句3
r = a * a; //语句4
这段代码有4个语句,那么可能的一个执行顺序是:
那么可不可能是这个执行顺序呢: 语句2 语句1 语句4 语句3
不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。
虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面看一个例子:
//线程1:
context = loadContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)
方法,而此时context
并没有被初始化,就会导致程序出错。
从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。
也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。
Java内存模型
在前面谈到了一些关于内存模型以及并发编程中可能会出现的一些问题。下面我们来看一下Java内存模型,研究一下Java内存模型为我们提供了哪些保证以及在Java中提供了哪些方法和机制来让我们在进行多线程编程时能够保证程序执行的正确性。
在Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。那么Java内存模型规定了哪些东西呢,它定义了程序中变量的访问规则,往大一点说是定义了程序执行的次序。注意,为了获得较好的执行性能,Java内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。也就是说,在Java内存模型中,也会存在缓存一致性问题和指令重排序的问题。
Java内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。
举个简单的例子:在Java中,执行下面这个语句:
i = 10;
执行线程必须先在自己的工作线程中对变量i所在的缓存行进行赋值操作,然后再写入主存当中。而不是直接将数值10写入主存当中。
那么Java语言 本身对 原子性、可见性以及有序性提供了哪些保证呢?
原子性
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。
上面一句话虽然看起来简单,但是理解起来并不是那么容易。看下面一个例子i:
请分析以下哪些操作是原子性操作:
x = 10; //语句1
y = x; //语句2
x++; //语句3
x = x + 1; //语句4
咋一看,有些朋友可能会说上面的4个语句中的操作都是原子性操作。其实只有语句1是原子性操作,其他三个语句都不是原子性操作。
语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。
语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
同样的,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。
所以上面4个语句只有语句1的操作具备原子性。
也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
不过这里有一点需要注意:在32位平台下,对64位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性。但是好像在最新的JDK
中,JVM
已经保证对64位数据的读取和赋值也是原子性操作了。
从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
可见性
对于可见性,Java提供了volatile关键字来保证可见性。
当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
有序性
在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
在Java里面,可以通过volatile关键字来保证一定的“有序性”(具体原理在下一节讲述)。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
另外,Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
下面就来具体介绍下happens-before
原则(先行发生原则):
程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
锁定规则:一个
unLock
操作先行发生于后面对同一个锁额lock操作volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过
Thread.join()
方法结束、Thread.isAlive()
的返回值手段检测到线程已经终止执行对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
这8条原则摘自《深入理解Java虚拟机》。
这8条规则中,前4条规则是比较重要的,后4条规则都是显而易见的。
下面我们来解释一下前4条规则:
对于程序次序规则来说,我的理解就是一段程序代码的执行在单个线程中看起来是有序的。注意,虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,因为虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。
第二条规则也比较容易理解,也就是说无论在单线程中还是多线程中,同一个锁如果出于被锁定的状态,那么必须先对锁进行了释放操作,后面才能继续进行lock操作。
第三条规则是一条比较重要的规则,也是后文将要重点讲述的内容。直观地解释就是,如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。
第四条规则实际上就是体现happens-before原则具备传递性。
synchronized
synchronized简单介绍
synchronized中文意思是同步,也称之为”同步锁“。
synchronized的作用是保证在同一时刻, 被修饰的代码块或方法只会有一个线程执行,以达到保证并发安全的效果。
synchronized是Java中解决并发问题的一种最常用的方法,也是最简单的一种方法。
在JDK1.5
之前synchronized是一个重量级锁,相对于j.u.c.Lock
,它会显得那么笨重,随着Javs SE 1.6
对synchronized进行的各种优化后,synchronized并不会显得那么重了。
synchronized的作用主要有三个:
-
原子性:确保线程互斥地访问同步代码;
-
可见性:保证共享变量的修改能够及时可见,其实是通过Java内存模型中的“对一个变量unlock操作之前,必须要同步到主内存中;如果对一个变量进行lock操作,则将会清空工作内存中此变量的值,在执行引擎使用此变量前,需要重新从主内存中load操作或assign操作初始化变量值” 来保证的;
-
有序性:有效解决重排序问题,即 “一个unlock操作先行发生(happen-before)于后面对同一个锁的lock操作”;
synchronized的使用
synchronized的3种使用方式:
-
修饰实例方法:作用于当前实例加锁
-
修饰静态方法:作用于当前类对象加锁
-
修饰代码块:指定加锁对象,对给定对象加锁
修饰方法
Synchronized修饰一个方法很简单,就是在方法的前面加synchronized,synchronized修饰方法和修饰一个代码块类似,只是作用范围不一样,修饰代码块是大括号括起来的范围,而修饰方法范围是整个函数。
方法一:
public synchronized void method() {
// todo
}
方法二:
public void method() {
synchronized(this) {
}
}
写法一修饰的是一个方法,写法二修饰的是一个代码块,但写法一与写法二是等价的,都是锁定了整个方法时的内容。
synchronized关键字不能继承。 虽然可以使用synchronized来定义方法,但synchronized并不属于方法定义的一部分,因此,synchronized关键字不能被继承。如果在父类中的某个方法使用了synchronized关键字,而在子类中覆盖了这个方法,在子类中的这个方法默认情况下并不是同步的,而必须显式地在子类的这个方法中加上synchronized关键字才可以。当然,还可以在子类方法中调用父类中相应的方法,这样虽然子类中的方法不是同步的,但子类调用了父类的同步方法,因此,子类的方法也就相当于同步了。这两种方式的例子代码如下:
在子类方法中加上synchronized关键字
class Parent {
public synchronized void method() { }
}
class Child extends Parent {
public synchronized void method() { }
}
在子类方法中调用父类的同步方法
class Parent {
public synchronized void method() { }
}
class Child extends Parent {
public void method() { super.method(); }
}
注意:
-
在定义接口方法时不能使用synchronized关键字。
-
构造方法不能使用synchronized关键字,但可以使用synchronized代码块来进行同步。
修饰一个代码块
-
一个线程访问一个对象中的synchronized(this)同步代码块时,其他试图访问该对象的线程将被阻塞。
注意下面两个程序的区别:
class SyncThread implements Runnable {
private static int count;
public SyncThread() {
count = 0;
}
public void run() {
synchronized(this) {
for (int i = 0; i < 5; i++) {
try {
System.out.println(Thread.currentThread().getName() + ":" + (count++));
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public int getCount() {
return count;
}
}
public class Demo00 {
public static void main(String args[]){
//调用方式一:test01
//SyncThread s1 = new SyncThread();
//SyncThread s2 = new SyncThread();
//Thread t1 = new Thread(s1);
//Thread t2 = new Thread(s2);
//调用方式二:test02
SyncThread s = new SyncThread();
Thread t1 = new Thread(s);
Thread t2 = new Thread(s);
t1.start();
t2.start();
}
}
调用方式二种,当两个并发线程(thread1
和thread2
)访问同一个对象(syncThread
)中的 synchronized
代码块时,在同一时刻只能有一个线程得到执行,另一个线程受阻塞,必须等待当前线程执行完这个代码块以后才能执行该代码块。thread1
和 thread2
是互斥的,因为在执行 synchronized
代码块时会锁定当前的对象,只有执行完该代码块才能释放该对象锁,下一个线程才能执行并锁定该对象
调用方式一中,thread1
和 thread2
同时在执行。这是因为 synchronized
只锁定对象,每个对象只有一个锁(lock
)与之相关联。
class Counter implements Runnable{
private int count;
public Counter() {
count = 0;
}
public void countAdd() {
synchronized(this) {
for (int i = 0; i < 5; i ++) {
try {
System.out.println(Thread.currentThread().getName() + ":" + (count++));
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
//非synchronized代码块,未对count进行读写操作,所以可以不用synchronized
public void printCount() {
for (int i = 0; i < 5; i ++) {
try {
System.out.println(Thread.currentThread().getName() + " count:" + count);
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public void run() {
String threadName = Thread.currentThread().getName();
if (threadName.equals("A")) {
countAdd();
} else if (threadName.equals("B")) {
printCount();
}
}
}
public class Demo00{
public static void main(String args[]){
Counter counter = new Counter();
Thread thread1 = new Thread(counter, "A");
Thread thread2 = new Thread(counter, "B");
thread1.start();
thread2.start();
}
}
可以看见B线程的调用是非 synchronized
,并不影响A线程对 synchronized
部分的调用。从上面的结果中可以看出一个线程访问一个对象的 synchronized
代码块时,别的线程可以访问该对象的非 synchronized
代码块而不受阻塞。
指定要给某个对象加锁
/**
* 银行账户类
*/
class Account {
String name;
float amount;
public Account(String name, float amount) {
this.name = name;
this.amount = amount;
}
//存钱
public void deposit(float amt) {
amount += amt;
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//取钱
public void withdraw(float amt) {
amount -= amt;
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public float getBalance() {
return amount;
}
}
/**
* 账户操作类
*/
class AccountOperator implements Runnable{
private Account account;
public AccountOperator(Account account) {
this.account = account;
}
public void run() {
synchronized (account) {
account.deposit(500);
account.withdraw(500);
System.out.println(Thread.currentThread().getName() + ":" + account.getBalance());
}
}
}
public class Demo00{
//public static final Object signal = new Object(); // 线程间通信变量
//将account改为Demo00.signal也能实现线程同步
public static void main(String args[]){
Account account = new Account("zhang san", 10000.0f);
AccountOperator accountOperator = new AccountOperator(account);
final int THREAD_NUM = 5;
Thread threads[] = new Thread[THREAD_NUM];
for (int i = 0; i < THREAD_NUM; i ++) {
threads[i] = new Thread(accountOperator, "Thread" + i);
threads[i].start();
}
}
}
在 AccountOperator
类中的 run
方法里,我们用 synchronized
给 account
对象加了锁。这时,当一个线程访问 account
对象时,其他试图访问 account
对象的线程将会阻塞,直到该线程访问 account
对象结束。也就是说谁拿到那个锁谁就可以运行它所控制的那段代码。 当有一个明确的对象作为锁时,就可以用类似下面这样的方式写程序:
public void method3(SomeObject obj)
{
//obj 锁定的对象
synchronized(obj)
{
// todo
}
}
当没有明确的对象作为锁,只是想让一段代码同步时,可以创建一个特殊的对象来充当锁:
class Test implements Runnable
{
private byte[] lock = new byte[0]; // 特殊的instance变量
public void method()
{
synchronized(lock) {
// todo 同步代码块
}
}
public void run() {
}
}
修饰一个静态方法
synchronized
也可修饰一个静态方法,用法如下:
public synchronized static void method() {
// todo
}
静态方法是属于类的而不属于对象的。同样的,synchronized
修饰的静态方法锁定的是这个类的所有对象。
/**
* 同步线程
*/
class SyncThread implements Runnable {
private static int count;
public SyncThread() {
count = 0;
}
public synchronized static void method() {
for (int i = 0; i < 5; i ++) {
try {
System.out.println(Thread.currentThread().getName() + ":" + (count++));
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public synchronized void run() {
method();
}
}
public class Demo00{
public static void main(String args[]){
SyncThread syncThread1 = new SyncThread();
SyncThread syncThread2 = new SyncThread();
Thread thread1 = new Thread(syncThread1, "SyncThread1");
Thread thread2 = new Thread(syncThread2, "SyncThread2");
thread1.start();
thread2.start();
}
}
修饰一个类
Synchronized还可作用于一个类,用法如下:
class ClassName {
public void method() {
synchronized(ClassName.class) {
// todo
}
}
}
/**
* 同步线程
*/
class SyncThread implements Runnable {
private static int count;
public SyncThread() {
count = 0;
}
public static void method() {
synchronized(SyncThread.class) {
for (int i = 0; i < 5; i ++) {
try {
System.out.println(Thread.currentThread().getName() + ":" + (count++));
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public synchronized void run() {
method();
}
}
本例的的给 class
加锁和上例的给静态方法加锁是一样的,所有对象公用一把锁。
使用总结
-
无论
synchronized
关键字加在方法上还是对象上,如果它作用的对象是非静态的,则它取得的锁是对象;如果synchronized
作用的对象是一个静态方法或一个类,则它取得的锁是对类,该类所有的对象同一把锁。 -
每个对象只有一个锁(
lock
)与之相关联,谁拿到这个锁谁就可以运行它所控制的那段代码。 -
实现同步是要很大的系统开销作为代价的,甚至可能造成死锁,所以尽量避免无谓的同步控制。
synchronized的底层实现
谈 synchronized
的底层实现,就不得不谈数据在 JVM
内存的存储:Java
对象头,以及 Monitor
对象监视器。
对象头
在 JVM
中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下图所示:
-
实例数据:存放类的属性数据信息,包括父类的属性信息;
-
对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;
-
对象头:
Java
对象头一般占有2个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit
,在64位虚拟机中,1个机器码是8个字节,也就是64bit
),但是如果对象是数组类型,则需要3个机器码,因为JVM
虚拟机可以通过Java
对象的元数据信息确定Java
对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
synchronized
用的锁就是存在 Java
对象头里的,那么什么是Java对象头呢?Hotspot
虚拟机的对象头主要包括两部分数据:Mark Word
(标记字段)、Class Pointer
(类型指针)。其中 Class Pointer
是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word
用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。 Java
对象头具体结构描述如下:
长度 | 内容 | 说明 |
---|---|---|
32/64 bit | Mark Word | 存储对象的hashCode或锁信息等。 |
32/64 bit | Class Metadata Address | 存储到对象类型数据的指针。 |
32/64 bit | Array length | 数组的长度(如果当前对象是数组)。 |
Mark Word
用于存储对象自身的运行时数据,如:哈希码(HashCode
)、GC
分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。下图是Java对象头 无锁状态下Mark Word
部分的存储结构(32位虚拟机):
25 bit | 4 bit | 1 bit是否是偏向锁 | 2 bit锁标志位 | |
---|---|---|---|---|
无锁状态 | 对象的hashCode | 对象分代年龄 | 0 | 01 |
对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化,可能变化为存储以下4种数据:
在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下:
对象头的最后两位存储了锁的标志位,01是初始状态,未加锁,其对象头里存储的是对象本身的哈希码,随着锁级别的不同,对象头里会存储不同的内容。偏向锁存储的是当前占用此对象的线程ID;而轻量级则存储指向线程栈中锁记录的指针。从这里我们可以看到,“锁”这个东西,可能是个锁记录+对象头里的引用指针(判断线程是否拥有锁时将线程的锁记录地址和对象头里的指针地址比较),也可能是对象头里的线程ID(判断线程是否拥有锁时将线程的ID和对象头里存储的线程ID比较)。
对象头中Mark Word与线程中Lock Record
在线程进入同步代码块的时候,如果此同步对象没有被锁定,即它的锁标志位是01,则虚拟机首先在当前线程的栈中创建我们称之为“锁记录(Lock Record
)”的空间,用于存储锁对象的Mark Word
的拷贝,官方把这个拷贝称为Displaced Mark Word
。整个Mark Word
及其拷贝至关重要。
Lock Record
是线程私有的数据结构,每一个线程都有一个可用Lock Record
列表,同时还有一个全局的可用列表。每一个被锁住的对象Mark Word
都会和一个Lock Record
关联(对象头的MarkWord
中的Lock Word
指向Lock Record
的起始地址),同时Lock Record
中有一个Owner
字段存放拥有该锁的线程的唯一标识(或者object mark word
),表示该锁被这个线程占用。如下图所示为Lock Record
的内部结构:
Lock Record | 描述 |
---|---|
Owner | 初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL; |
EntryQ | 关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程; |
RcThis | 表示blocked或waiting在该monitor record上的所有线程的个数; |
Nest | 用来实现 重入锁的计数; |
HashCode | 保存从对象头拷贝过来的HashCode值(可能还包含GC age)。 |
Candidate | 用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争锁。 |
监视器(Monitor)
任何一个对象都有一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态。
synchronized
在 JVM
里的实现都是 基于进入和退出 Monitor
对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的 MonitorEnter
和 MonitorExit
指令来实现。
MonitorEnter
指令:插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象 Monitor
的所有权,即尝试获得该对象的锁;
MonitorExit
指令:插入在方法结束处和异常处,JVM
保证每个 MonitorEnter
必须有对应的 MonitorExit
;
那什么是Monitor
?可以把它理解为 一个同步工具,也可以描述为 一种同步机制,它通常被 描述为一个对象。
与一切皆对象一样,所有的Java对象是天生的Monitor
,每一个Java对象都有成为Monitor
的潜质,因为在Java
的设计中 ,每一个Java
对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor
锁。
也就是通常说Synchronized
的对象锁,MarkWord
锁标识位为10,其中指针指向的是Monitor
对象的起始地址。在Java虚拟机(HotSpot
)中,Monitor
是由ObjectMonitor
实现的,其主要数据结构如下(位于HotSpot
虚拟机源码ObjectMonitor.hpp
文件,C++实现的):
ObjectMonitor() {
_header = NULL;
_count = 0; // 记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; // 处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
ObjectMonitor
中有两个队列,_WaitSet
和 _EntryList
,用来保存ObjectWaiter
对象列表( 每个等待锁的线程都会被封装成ObjectWaiter
对象 ),_owner
指向持有ObjectMonitor
对象的线程,当多个线程同时访问一段同步代码时:
-
首先会进入
_EntryList
集合,当线程获取到对象的monitor
后,进入_Owner
区域并把monitor
中的owner
变量设置为当前线程,同时monitor
中的计数器count
加1; -
若线程调用
wait()
方法,将释放当前持有的monitor
,owner
变量恢复为null
,count
自减1,同时该线程进入WaitSet
集合中等待被唤醒; -
若当前线程执行完毕,也将释放monitor(锁)并复位count的值,以便其他线程进入获取monitor(锁);
同时,Monitor
对象存在于每个Java对象的对象头Mark Word
中(存储的指针的指向),Synchronized
锁便是通过这种方式获取锁的,也是为什么Java
中任意对象可以作为锁的原因,同时notify/notifyAll/wait
等方法会使用到Monitor锁对象,所以必须在同步代码块中使用。
监视器Monitor
有两种同步方式:互斥与协作。多线程环境下线程之间如果需要共享数据,需要解决互斥访问数据的问题,监视器可以确保监视器上的数据在同一时刻只会有一个线程在访问。
什么时候需要协作? 比如:
一个线程向缓冲区写数据,另一个线程从缓冲区读数据,如果读线程发现缓冲区为空就会等待,当写线程向缓冲区写入数据,就会唤醒读线程,这里读线程和写线程就是一个合作关系。JVM
通过Object
类的wait
方法来使自己等待,在调用wait
方法后,该线程会释放它持有的监视器,直到其他线程通知它才有执行的机会。一个线程调用notify
方法通知在等待的线程,这个等待的线程并不会马上执行,而是要通知线程释放监视器后,它重新获取监视器才有执行的机会。如果刚好唤醒的这个线程需要的监视器被其他线程抢占,那么这个线程会继续等待。Object
类中的notifyAll
方法可以解决这个问题,它可以唤醒所有等待的线程,总有一个线程执行。
如上图所示,一个线程通过1号门进入Entry Set(入口区)
,如果在入口区没有线程等待,那么这个线程就会获取监视器成为监视器的Owner
,然后执行监视区域的代码。如果在入口区中有其它线程在等待,那么新来的线程也会和这些线程一起等待。线程在持有监视器的过程中,有两个选择,一个是正常执行监视器区域的代码,释放监视器,通过5号门退出监视器;还有可能等待某个条件的出现,于是它会通过3号门到Wait Set(等待区)
休息,直到相应的条件满足后再通过4号门进入重新获取监视器再执行。
注意:当一个线程释放监视器时,在入口区和等待区的等待线程都会去竞争监视器,如果入口区的线程赢了,会从2号门进入;如果等待区的线程赢了会从4号门进入。只有通过3号门才能进入等待区,在等待区中的线程只有通过4号门才能退出等待区,也就是说一个线程只有在持有监视器时才能执行wait
操作,处于等待的线程只有再次获得监视器才能退出等待状态。
synchronized 锁的升级顺序
锁解决了数据的安全性,但是同样带来了性能的下降。hotspot
虚拟机的作者经过调查发现,大部分情况下,加锁的代码不仅仅不存在多线程竞争,而且总是由同一个线程多次获得。所以基于这样一个概率。 synchronized
在 JDK1.6
之后做了一些优化,为了减少获得锁和释放锁来的性能开销,引入了偏向锁、轻量级锁、自旋锁、重量级锁,锁的状态根据竞争激烈的程度从低到高不断升级。
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁。但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。而且这个过程就是开销逐渐加大的过程。
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外 的消耗,和执行非同步 方法比仅存在纳秒级的 差距。 | 如果线程间存在锁竞争,会 带来额外的锁撤销的消耗。 | 适用于只有一个线程访 问同步块场景。 |
轻量级锁 | 竞争的线程不会阻塞, 提高了程序的响应速度。 | 如果始终得不到锁竞争的线 程使用自旋会消耗CPU。 | 追求响应时间。 同步块执行速度非常快。 |
重量级锁 | 线程竞争不使用自旋, 不会消耗CPU。 | 线程阻塞,响应时间缓慢。 | 追求吞吐量。 同步块执行速度较长。 |
volatile关键字
volatile 的主要作用有两点:
-
保证变量的内存可见性
-
禁止指令重排序
可见性问题
先来看看这个比较常见的多线程访问共享变量的例子。
/**
* 变量的内存可见性例子
*
* @author star
*/
public class VolatileExample {
/**
* main 方法作为一个主线程
*/
public static void main(String[] args) {
MyThread myThread = new MyThread();
// 开启线程
myThread.start();
// 主线程执行
for (; ; ) {
if (myThread.isFlag()) {
System.out.println("主线程访问到 flag 变量");
}
}
}
}
/**
* 子线程类
*/
class MyThread extends Thread {
private boolean flag = false;
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 修改变量值
flag = true;
System.out.println("flag = " + flag);
}
public boolean isFlag() {
return flag;
}
public void setFlag(boolean flag) {
this.flag = flag;
}
}
执行上面的程序,你会发现,控制台永远都不会输出 “主线程访问到 flag 变量” 这句话。我们可以看到,子线程执行时已经将 flag 设置成 true,但主线程执行时没有读到 flag 的最新值,导致控制台没有输出上面的句子。
那么,我们思考一下为什么会出现这种情况呢?这里我们就要了解一下 Java 内存模型(简称 JMM
)。
Java 内存模型
JMM(Java Memory Model)
:Java 内存模型,是 Java 虚拟机规范中所定义的一种内存模型,Java 内存模型是标准化的,屏蔽掉了底层不同计算机的区别。也就是说,JMM
是 JVM
中定义的一种并发编程的底层模型机制。
JMM
定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。
JMM
的规定:
-
所有的共享变量都存储于主内存。这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
-
每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
-
线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。
-
不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。
JMM
的抽象示意图:
然而,JMM 这样的规定可能会导致线程对共享变量的修改没有即时更新到主内存,或者线程没能够即时将共享变量的最新值同步到工作内存中,从而使得线程在使用共享变量的值时,该值并不是最新的。
正因为 JMM 这样的机制,就出现了可见性问题。也就是我们上面那个例子出现的问题。
那我们要如何解决可见性问题呢?接下来我们就聊聊内存可见性以及可见性问题的解决方案。
内存可见性
内存可见性是指当一个线程修改了某个变量的值,其它线程总是能知道这个变量变化。也就是说,如果线程 A 修改了共享变量 V 的值,那么线程 B 在使用 V 的值时,能立即读到 V 的最新值。
可见性问题的解决方案
我们如何保证多线程下共享变量的可见性呢?也就是当一个线程修改了某个值后,对其他线程是可见的。
这里有两种方案:加锁 和 使用 volatile 关键字。
下面我们使用这两个方案对上面的例子进行改造。
加锁
使用 synchronizer 进行加锁。
/**
* main 方法作为一个主线程
*/
public static void main(String[] args) {
MyThread myThread = new MyThread();
// 开启线程
myThread.start();
// 主线程执行
for (; ; ) {
synchronized (myThread) {
if (myThread.isFlag()) {
System.out.println("主线程访问到 flag 变量");
}
}
}
}
这里大家应该有个疑问是,为什么加锁后就保证了变量的内存可见性了? 因为当一个线程进入 synchronizer 代码块后,线程获取到锁,会清空本地内存,然后从主内存中拷贝共享变量的最新值到本地内存作为副本,执行代码,又将修改后的副本值刷新到主内存中,最后线程释放锁。
这里除了 synchronizer 外,其它锁也能保证变量的内存可见性。
使用 volatile 关键字
使用 volatile 关键字修饰共享变量。
/**
* 子线程类
*/
class MyThread extends Thread {
private volatile boolean flag = false;
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 修改变量值
flag = true;
System.out.println("flag = " + flag);
}
public boolean isFlag() {
return flag;
}
public void setFlag(boolean flag) {
this.flag = flag;
}
}
使用 volatile 修饰共享变量后,每个线程要操作变量时会从主内存中将变量拷贝到本地内存作为副本,当线程操作变量副本并写回主内存后,会通过 CPU 总线嗅探机制告知其他线程该变量副本已经失效,需要重新从主内存中读取。
volatile 保证了不同线程对共享变量操作的可见性,也就是说一个线程修改了 volatile 修饰的变量,当修改后的变量写回主内存时,其他线程能立即看到最新值。
接下来我们就聊聊一个比较底层的知识点:总线嗅探机制
。
总线嗅探机制
在现代计算机中,CPU 的速度是极高的,如果 CPU 需要存取数据时都直接与内存打交道,在存取过程中,CPU 将一直空闲,这是一种极大的浪费,所以,为了提高处理速度,CPU 不直接和内存进行通信,而是在 CPU 与内存之间加入很多寄存器,多级缓存,它们比内存的存取速度高得多,这样就解决了 CPU 运算速度和内存读取速度不一致问题。
由于 CPU 与内存之间加入了缓存,在进行数据操作时,先将数据从内存拷贝到缓存中,CPU 直接操作的是缓存中的数据。但在多处理器下,将可能导致各自的缓存数据不一致(这也是可见性问题的由来),为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,而嗅探是实现缓存一致性的常见机制。
注意,缓存的一致性问题,不是多处理器导致,而是多缓存导致的。
嗅探机制工作原理:每个处理器通过监听在总线上传播的数据来检查自己的缓存值是不是过期了,如果处理器发现自己缓存行对应的内存地址修改,就会将当前处理器的缓存行设置无效状态,当处理器对这个数据进行修改操作的时候,会重新从主内存中把数据读到处理器缓存中。
注意:基于 CPU 缓存一致性协议,
JVM
实现了 volatile 的可见性,但由于总线嗅探机制,会不断的监听总线,如果大量使用 volatile 会引起总线风暴。所以,volatile 的使用要适合具体场景。
可见性问题小结
上面的例子中,我们看到,使用 volatile 和 synchronized 锁都可以保证共享变量的可见性。相比 synchronized 而言,volatile 可以看作是一个轻量级锁,所以使用 volatile 的成本更低,因为它不会引起线程上下文的切换和调度。但 volatile 无法像 synchronized 一样保证操作的原子性。
volatile 的原子性问题
所谓的原子性是指在一次操作或者多次操作中,要么所有的操作全部都得到了执行并且不会受到任何因素的干扰而中断,要么所有的操作都不执行。
在多线程环境下,volatile 关键字可以保证共享数据的可见性,但是并不能保证对数据操作的原子性。也就是说,多线程环境下,使用 volatile 修饰的变量是线程不安全的。
要解决这个问题,我们可以使用锁机制,或者使用原子类(如 AtomicInteger
)。
这里特别说一下,对任意单个使用 volatile 修饰的变量的读 / 写是具有原子性,但类似于 flag = !flag
这种复合操作不具有原子性。简单地说就是,单纯的赋值操作是原子性的。
禁止指令重排序
什么是重排序?
为了提高性能,在遵守 as-if-serial
语义(即不管怎么重排序,单线程下程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守。)的情况下,编译器和处理器常常会对指令做重排序。
一般重排序可以分为如下三种类型:
-
编译器优化重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
-
指令级并行重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
-
内存系统重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
数据依赖性:如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
从 Java 源代码到最终执行的指令序列,会分别经历下面三种重排序:
为了更好地理解重排序,请看下面的部分示例代码:
int a = 0;
// 线程 A
a = 1; // 1
flag = true; // 2
// 线程 B
if (flag) { // 3
int i = a; // 4
}
单看上面的程序好像没有问题,最后 i 的值是 1。但是为了提高性能,编译器和处理器常常会在不改变数据依赖的情况下对指令做重排序。假设线程 A 在执行时被重排序成先执行代码 2,再执行代码 1;而线程 B 在线程 A 执行完代码 2 后,读取了 flag 变量。由于条件判断为真,线程 B 将读取变量 a。此时,变量 a 还根本没有被线程 A 写入,那么 i 最后的值是 0,导致执行结果不正确。那么如何程序执行结果正确呢?这里仍然可以使用 volatile 关键字。
这个例子中, 使用 volatile 不仅保证了变量的内存可见性,还禁止了指令的重排序,即保证了 volatile 修饰的变量编译后的顺序与程序的执行顺序一样。那么使用 volatile 修饰 flag 变量后,在线程 A 中,保证了代码 1 的执行顺序一定在代码 2 之前。
那么,让我们继续往下探索, volatile 是如何禁止指令重排序的呢?这里我们将引出一个概念:内存屏障指令
内存屏障指令
为了实现 volatile 内存语义(即内存可见性),JMM
会限制特定类型的编译器和处理器重排序。为此,JMM
针对编译器制定了 volatile 重排序规则表,如下所示:
是否重排序 | 第二次操作 | ||
---|---|---|---|
第一次操作 | 普通读/写 | volatile读 | volatile写 |
普通读/写 | YES | YES | NO |
volatile读 | NO | NO | NO |
volatile写 | YES | NO | NO |
使用 volatile 修饰变量时,根据 volatile 重排序规则表,Java 编译器在生成字节码时,会在指令序列中插入内存屏障指令来禁止特定类型的处理器重排序。
内存屏障
是一组处理器指令,它的作用是禁止指令重排序和解决内存可见性的问题。
JMM
把内存屏障指令分为下列四类:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad Barriers | Load1;LoadLoad;Load2 | 确保Load1数据的装载先于Load2及所有后续装载指令的装载 |
StoreStore Barriers | Store1;StoreStore;Stored | 确保Store1数据对其他处理器可见(刷新到内存)先于Store2 及所有后续存储指令的存储 |
LoadStore Barriers | Load1;LoadStore;Store2 | 确保Load1数据装载咸鱼Store2及所有后续的存储指令刷新到内存 |
StoreLoad Barriers | Store1;Store Load;Load2 | 确保Store1数据对其他处理器变得可见(指刷新到内存)先于Load2 及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的 所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后 的内存访问指令 |
final域
概述
final关键字代表最终、不可改变的。 常见四种用法:
-
可以用来修饰一个类
-
可以用来修饰一个方法
-
还可以用来修饰一个局部变量
-
还可以用来修饰一个成员变量
final域和线程安全有什么关系?
对于final域,编译器和处理器要遵守两个重排序规则。
-
在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一 个引用变量,这两个操作之间不能重排序。
-
初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操 作之间不能重排序。
final修饰类:太监类,没有儿子
当final关键字用来修饰一个类的时候,格式:
public final class 类名称 { // ... }
含义:当前这个类不能有任何的子类。(太监类)
注意:一个类如果是final的,那么其中所有的成员方法都无法进行覆盖重写(因为没儿子。)
final修饰方法
-
含义:
当final关键字用来修饰一个方法的时候,这个方法就是最终方法,也就是不能被覆盖重写。
-
格式:
修饰符 final 返回值类型 方法名称(参数列表) {
// 方法体
}
注意事项:对于类、方法来说,abstract关键字和final关键字不能同时使用,因为矛盾。有抽象方法的abstract类被继承时,其中的方法必须被子类Override,而final不能被Override。
`
public abstract class Fu {
public final void method() {
System.out.println("父类方法执行!");
}
public abstract /*final*/ void methodAbs() ; //有final会报红
}
public class Zi extends Fu {
@Override
public void methodAbs() {
}
// 错误写法!不能覆盖重写父类当中final的方法
// @Override
// public void method() {
// System.out.println("子类覆盖重写父类的方法!");
// }
}
final修饰局部变量
// 正确写法!只要保证有唯一一次赋值即可
final int num3;
num3 = 30;
对于基本类型来说,不可变说的是变量当中的数据不可改变 对于引用类型来说,不可变说的是变量当中的地址值不可改变
一个标准类:
public class Student {
private String name;
public Student() {
}
public Student(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
Main函数里:
-
若无final修饰
public class TestDemo {
public static void main(String[] args) {
final Student stu1 = new Student("张三");
System.out.println(stu1); // 打印地址
System.out.println(stu1.getName()); // 张三
stu1 = new Student("李四");
System.out.println(stu1);
System.out.println(stu1.getName()); // 李四
}
}
结果输出:地址不同
com.gupaoedu.Student@7006c658
张三
com.gupaoedu.Student@34033bd0
李四
-
若用final修饰,地址值不可改变,否则报红
但是,地址值虽然不能变,内容可以变(地址所指向的对象可以变)。
public class TestDemo {
public static void main(String[] args) {
final Student stu1 = new Student("张三");
System.out.println(stu1);
System.out.println(stu1.getName());
stu1.setName("李四");
System.out.println(stu1);
System.out.println(stu1.getName()); // 李四
}
}
输出结果:
com.gupaoedu.Student@7006c658
张三
com.gupaoedu.Student@7006c658
李四
final修饰成员变量
对于成员变量来说,如果使用final关键字修饰,那么这个变量也照样是不可变。
-
由于成员变量具有默认值,所以用了final之后必须手动赋值,不会再给默认值。
-
对于final的成员变量,要么使用直接赋值,要么通过构造方法赋值。二者选其一。
-
必须保证类当中所有重载的构造方法,都最终会对final的成员变量进行赋值。
如果选择在构造方法中赋值,则要把setname()
函数取消掉。
Happens-Before原则
happens-before 定义
happens-before的概念最初由Leslie Lamport在其一篇影响深远的论文(《Time,Clocks and the Ordering of Events in a Distributed System》)中提出。JSR-133
使用happens-before的概念来指定两个操作之间的执行顺序。由于这两个操作可以在一个线程之内,也可以是在不同线程之间。因此,JMM
可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM
向程序员保证a操作将对b操作可见)。具体的定义为:
-
如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
-
两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,
JMM
允许这种重排序)。上面的 1 是
JMM
对程序员的承诺。从程序员的角度来说,可以这样理解happens-before关系:如果A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。注意,这只是Java内存模型向程序员做出的保证!2 是
JMM
对编译器和处理器重排序的约束原则。正如前面所言,JMM
其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。JMM
这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before关系本质上和as-if-serial语义是一回事。
具体规则 :
具体的一共有六项规则:
程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。 对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。
原子类Atomic
什么是原子类?用处是什么?如何使用?
在java.util.concurrent.atomic
包下,有一系列“Atomic”开头的类,统称为原子类。
考虑以下情景:
private static final ExecutorService exec = Executors.newCachedThreadPool();
private static int i = 0;
public static void main(String[] args) throws InterruptedException {
Runnable r = () -> {
for (int k = 0; k < 1000; k++) {
i++;
sleep(1); // 交出CPU控制权,增大竞争
}
};
for (int j = 0; j < 10; j++) {
Future<?> f = exec.submit(r);
}
exec.shutdown();
exec.awaitTermination(60, TimeUnit.SECONDS); // 等待所有任务执行完毕
System.out.println(i);
}
在多个线程竞争同一个资源的情形下,可能会造成意想不到的错误,如上述代码i
本应输出10000,实际情况下是小于10000的。 这是由于i++
这一步在字节码中分为了四次操作:getstatic
将静态变量i
的值入栈,iconst_1
将1入栈,iadd
将栈顶两个数出栈、并将它们的和入栈,putstatic
将栈顶的值赋给变量i
。
这就导致了i++
不能保证单步代码的原子性,所以线程不安全。如果想要使其变得线程安全,可以使用synchronized
同步代码块进行加锁:
private static final ExecutorService exec = Executors.newCachedThreadPool();
private static int i = 0;
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
Runnable r = () -> {
for (int k = 0; k < 1000; k++) {
synchronized (lock) { // 使用同步代码块
i++;
}
sleep(1);
}
};
for (int j = 0; j < 10; j++) {
Future<?> f = exec.submit(r);
}
exec.shutdown();
exec.awaitTermination(60, TimeUnit.SECONDS);
System.out.println(i);
}
另外一种方式就是使用Atomic类:
private static final ExecutorService exec = Executors.newCachedThreadPool();
//private static int i = 0;
private static AtomicInteger i = new AtomicInteger(0); // 使用AtomicInteger类保证原子性
public static void main(String[] args) throws InterruptedException {
Runnable r = () -> {
for (int k = 0; k < 1000; k++) {
i.getAndIncrement(); // 使用AtomicInteger类保证原子性
sleep(1);
}
};
for (int j = 0; j < 10; j++) {
Future<?> f = exec.submit(r);
}
exec.shutdown();
exec.awaitTermination(60, TimeUnit.SECONDS);
System.out.println(i);
}
使用AtomicInteger
替代int
,同时使用i.getAndIncrement()
替代i++
,也可以保证这一步的原子性。
为什么Atomic类可以保证原子性?
以AtomicInteger
为例。在AtomicInteger
中有一个volatile修饰的value
变量,也就是这个整型的值。在调用getAndIncrement()
时,AtomicInteger
会通过Unsafe
类的getAndAddInt
方法对变量value
进行一次CAS
操作。由于CAS
是具有原子性的,所以AtomicInteger
就保证了操作的线程安全。
ThreadLocal的实现原理
什么是 ThreadLocal?
ThreadLocal
是 Java 里一种特殊变量,它是一个线程级别变量,每个线程都有一个 ThreadLocal
就是每个线程都拥有了自己独立的一个变量,竞态条件被彻底消除了,在并发模式下是绝对安全的变量。
可以通过
ThreadLocal<T> value = new ThreadLocal<T>();
来使用。
会自动在每一个线程上创建一个 T 的副本,副本之间彼此独立,互不影响,可以用 ThreadLocal
存储一些参数,以便在线程中多个方法中使用,用以代替方法传参的做法。
ThreadLocal 内存泄漏
ThreadLocal
在没有外部强引用时,发生 GC
时会被回收,那么 ThreadLocalMap
中保存的 key 值就变成了 null,而 Entry 又被 threadLocalMap
对象引用,threadLocalMap
对象又被 Thread 对象所引用,那么当 Thread 一直不终结的话,value 对象就会一直存在于内存中,也就导致了内存泄漏,直至 Thread 被销毁后,才会被回收。
那么如何避免内存泄漏呢?
在使用完 ThreadLocal
变量后,需要我们手动 remove 掉,防止 ThreadLocalMap
中 Entry 一直保持对 value 的强引用,导致 value 不能被回收。
ThreadLocal 应用场景
ThreadLocal
的特性也导致了应用场景比较广泛,主要的应用场景如下:
线程间数据隔离,各线程的
ThreadLocal
互不影响方便同一个线程使用某一对象,避免不必要的参数传递
全链路追踪中的
traceId
或者流程引擎中上下文的传递一般采用ThreadLocal
Spring
事务管理器采用了ThreadLocal
Spring MVC
的RequestContextHolder
的实现使用了ThreadLocal