多线程安全
1.多线程安全问题
当多个线程同时共享,同一个全局变量或静态变量,做写的操作时,可能会发生数据冲突问题,也就是线程安全问题,但是做读操作是不会发生数据冲突问题。
经典实例:两个窗口同时出售100张票
public class TicketThread —— Runnable {
private int ticketCount = 100;
@override run():
while(ticketCount > 0) {
try : Thread.sleep(50);
sale();
}
public void sale():
if(ticketCount > 0) {
sysout(Thread.currentThread().getName() + "正在出售" + (100 - ticketCount + 1) + "张票");
ticketCount--;
}
}
main(): TicketThread ticketThread = new TicketThread();
Thread t1 = new Thread(ticketThread, "窗口1--");
Thread t2 = new Thread(ticketThread, "窗口2--");
t1.start();
t2.start();
如何解决多线程之间线程安全问题:同步synchronized或锁lock。
2.synchronized使用及原理
2.1 synchronized 使用
synchronized(内置锁、互斥锁、可重入锁)
- 1.synchronized方法:Java中的每个对象都有一个锁(Lock)或者叫做监听器(Monitor),当访问某个对象的synchronized方法时,表示将该对象上锁,此时其他任何线程都无法再去访问该对象的任何synchronized方法,直到之前的那个线程执行方法完毕后(或者抛出了异常),那么将该对象的锁释放掉,其他线程才有可能访问对象的synchronized方法。
- 2.synchronized static : 如果某个synchronized 方法是static的,那么锁住的是synchronized 方法所在的对象对应的Class对象。同理其他线程无法访问synchronized static 方法。
synchronized : this.synchronized
synchronized static : class.synchronized
- 3.synchronized 块:
synchronized (object) {
}
表示线程执行的时候会对object对象上锁
synchronized 方法是一种粗粒度的并发控制。在某一时刻,只能有一个线程执行该synchronized 方法。
synchronized 块则是一种细粒度的并发控制,只会将块中的代码同步,位于方法内,synchronized 块之外的代码是可以被多个线程同时访问到的。
synchronized 块锁住的对象和synchronized 方法锁住的对象一样,则不能同时访问synchronized 方法。
2.2 synchronized 原理
synchronized 代码块原理:
public void method(){
synchronized(this) {
}
}
// 反编译:
public void method();
code:
0: ---
1: dup
2:
3:monitorenter
...
13:monitorexit
monitorenter:
// 每个对象都有一个监视器锁monitor。当monitor被占用时就会处于锁定状态。
// 线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
// 1.如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者
// 2.如果线程已经占有该monitor,只是重新进入,则进入monitor的数加1
// 3.如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
monitorexit:
// 1.执行monitorexit的线程必须是objectref所对应的monitor的所有者。
// 2.指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。
synchronized方法原理:
public synchronized void method() {
}
// 反编译
public synchronized void method();
descriptor:()V
flags:ACC_SYNCHRONIZED
code:
相对于普通方法,常量池多了ACC_SYNCHRONIZED标识符
当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标识是否被设置,如果设置了,执行线程将会
先获取monitor,获取成功之后,才能执行方法体,方法执行完后再释放monitor。
在方法执行期间,其他任何线程都无法再获得同一个monitor对象。
3.多线程死锁
死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。
经典事例:synchronized 嵌套
final Object lockA = new Object();
final Object lockB = new Object();
class ThreadA —— Runnable:
@override run():
synchronized(lockA):
try:Thread.sleep(2000);
synchronized(lockB):
try:Thread.sleep(2000);
class ThreadB —— Runnable:
@override run():
synchronized(lockB):
try:Thread.sleep(2000);
synchronized(lockA):
try:Thread.sleep(2000);
main():
ThreadA A = new ThreadA();
ThreadB B = new ThreadB();
Thread AThread = new Thread(A);
Thread BThread = new Thread(B);
AThread.start();
BThread.start();
4.ThreadLocal:提供线程内的局部变量
4.1 ThreadLocal介绍
很多文章在对比ThreadLocal与synchronized异同,既然是作比较,那应该是认为这两者解决相同或者类似的问题,上面的描述,问题在于,ThreadLocal并不解决多线程共享变量的问题。
正确理解:
- 1.ThreadLocal提供了线程安全的实例,它与普通变量的区别在于,每个使用该变量的线程都会初始化一个完全独立的实例副本
- 2.ThreadLocal变量通常被private static 修饰.
简单示例:
class MyRunnable —— Runnable:
public int count; // 共用同一份变量
@override run():
for(int i = 0; i < 3; i++)
count++;
class MyRunnable —— Runnable:
private static ThreadLocal<Integer> countLocal = new ThreadLocal<Integer>(){
protected Integer initialVal(){
return 1;
}
}
@override run():
for(int i = 0; i < 3; i++){
int count = countLocal.get();
countLocal.set(count);
}
4.2 ThreadLocal原理
1)最常见的使用方式:
public static ThreadLocal<Session> session = ThreadLocal.withInitial(() -> new Session());
(注意)虽然所有的线程都能访问到这个ThreadLocal实例,但是每个线程只能访问到自己调用ThreadLocal的set设置的值。
2)实现原理:
/**
* @author Josh Bloch and Doug Lea
* @since 1.2
*/
public class ThreadLocal<T> {
/**
* Sets the current thread's copy of this thread-local variable
* to the specified value. Most subclasses will have no need to
* override this method, relying solely on the {@link #initialValue}
* method to set the values of thread-locals.
*
* @param value the value to be stored in the current thread's copy of
* this thread-local.
*/
public void set(T value) {
// 1.获取当前线程
Thread t = Thread.currentThread();
// 2.获取该线程的ThreadLocalMap对象
ThreadLocalMap map = getMap(t);
// 3.若该线程的ThreadLocalMap对象已存在,则替换该map里的值
// 否则创建1个ThreadLocalMap对象。
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
/**
* Returns the value in the current thread's copy of this
* thread-local variable. If the variable has no value for the
* current thread, it is first initialized to the value returned
* by an invocation of the {@link #initialValue} method.
*
* @return the current thread's value of this thread-local
*/
public T get() {
// 1.获取当前线程
Thread t = Thread.currentThread();
// 2.获取该线程的ThreadLocalMap对象
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
/**
* Variant of set() to establish initialValue. Used instead
* of set() in case user has overridden the set() method.
*
* @return the initial value
*/
private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}
/**
* Get the map associated with a ThreadLocal. Overridden in
* InheritableThreadLocal.
*
* @param t the current thread
* @return the map
*/
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
/**
* Create the map associated with a ThreadLocal. Overridden in
* InheritableThreadLocal.
*
* @param t the current thread
* @param firstValue value for the initial entry of the map
*/
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
}
/*
*
* @author unascribed
* @see Runnable
* @see Runtime#exit(int)
* @see #run()
* @see #stop()
* @since JDK1.0
*/
public class Thread implements Runnable {
/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;
}
(思考)ThreadLocalMap的键的key = 当前ThreadLocal实例,为什么不是当前Thread本身。
(参考)获取ThreadLocal变量里的值是通过Thread里的ThreadLocalMap获得的,而每个Thread可能存有多个ThreadLocal实例的变量,所以通过this对象去得到当前ThreadLocal存储的变量。
4.3 必读文章
5.ThreadLocal适用场景
对于JavaWeb,Session保存了很多信息,很多时候需要通过Session来获取信息,有些时候又需要修改Session的信息。
一方面,需要保证每个线程都有自己单独的Session实例。另一方面,由于很多地方都需要操作Session,存在多方法共享Session的需求。
如果不使用ThreadLocal,可以在每个线程内构建一个Session实例,并将该实例在多个方法间传递。
如下所示:
public class SessionHandler{
@Data
public static class Session{
private String id;
private String user;
private String status;
}
public Session createSession(){
return new Session();
}
public String getUser(Session session){
return session.getUser();
}
}
main():
new Thread(() -> {
SessionHandler handler = new SessionHandler();
Session session = handler.createSession();
handler.getUser(session);
})
// 可以实现需求,但是每个需要使用Session的地方都需要
// 显示传递Session对象,方法耦合度高。
使用ThreadLocal重新实现该功能:
public class SessionHandler {
public static ThreadLocal<Session> session = ThreadLocal.withInitial(() -> new Session());
@Data
public static class Session{
private String id;
private String user;
private String status;
}
public String getUser(Session session){
return session.get().getUser();
}
public String getStatus(Session session){
return session.get().getStatus();
}
}
main():
new Thread(() -> {
SessionHandler handler = new SessionHandler();
handler.getUser();
handler.getStatus();
})
6.多线程的三大特性
6.1 原子性
一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就不执行。
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。
请分析一下哪些操作是原子性操作:即这些操作是不可被中断的,要么执行,要么不执行。
x = 10; // 语句1
y = x; // 语句2
x++; // 语句3
x = x + 1; //语句4
只有语句1是原子性操作,其他三个语句都不是原子性操作。
语句1:赋值语句,线程执行这个语句会直接将数值10写入到工作内存中。
语句2:实际上包含2个操作,它先要读取x的值,再将x的值,写入到工作内存,虽然读取x的值以及将x的值写入工作内存这两个操作是原子性操作,但是放在一起就不是原子性操作了。
同样的,x++和x = x + 1 包含3个操作,读取x的值,进行加1,写入新值。
所以上面4个语句只有语句1的操作具有原子性。
从上面可以看出,Java内存模型,只保证了基本读取和赋值操作是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和lock来实现,由于synchronized和lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了。
6.2 可见性
当多线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
示例:
class VolatileDemo extends Thread {
public boolean flag = true;
@override run(){
sysout("开始执行子线程...");
while(flag){
}
sysout("线程停止");
}
void setRunning(boolean flag){
this.flag = flag;
}
}
main(){
VolatileDemo demo = new VolatileDemo();
demo.start();
Thread.sleep(3000);
demo.setRunning(false);
sysout("flag已经设置成false")
Thread.sleep(1000);
sysout(demo.flag); // false
// 主内存已经设置为false,但是demo并没有去读。
}
分析结果:虽然已经将flag设置为false,但是线程之间是不可见得,读取的是副本,没有及时读取到主内存结果。
volatile 变量:
- 1.当线程对volatile变量进行写操作时,会将修改后的值刷新回主内存。
- 2.当线程对volatile变量进行读操作时,会将自己工作内存中的变量置为无效,之后再通过主内存拷贝新值到工作内存中使用。
普通的共享变量不能保证可见性,另外通过synchronized和lock也能够保证可见性,synchronized和lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主内存中,因此可以保证可见性。
6.3 有序性
程序的执行结果按照代码的先后顺序执行
int i = 0;
boolean falg = false;
i = 1; // 语句1
flag = true; // 语句2
从这段代码顺序上来看,语句1是在语句2前面的,那么JVM真正在执行这段代码的时候会保证语句1一定在语句2之前吗?
不一定。为什么呢?这里可能会发生指令重排序。
指令重排序:一般来说,处理器为了提高程序运行效率,可能会对输入的代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证最终执行结果和代码顺序的结果是一致的。
在上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。
虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同。
int a = 10; // 语句1
int r = 2; // 语句2
a = a + 3; // 语句3
r = a * a; // 语句4
可不可能是这个执行顺序:2 1 4 3
不可能,因为处理器在进行指令重排序时会考虑指令之间的数据依赖性,如果一个指令instruction2必须用到instruction1的结果,那么处理器会保证instruction1会在instruction2之前执行。
虽然重排序不会影响单个线程内程序执行的结果,但是多线程内:
// 线程1:
context = loadContext(); // 语句1
inited = true; // 语句2
// 线程2:
while(!inited){
sleep();
}
doSomethingWithContext(context);
语句1和语句2没有数据依赖性,假如发生了重排序,
在线程1执行过程中先执行语句2,而此时线程2会认为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingWithContext(context)方法,而此时context没有被初始化,就会导致程序出错。
指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。
要想并发程序正确的执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能导致程序运行不正确。
- 1.保证原子性:
1)使用j.u.c.atomic下的工具类,如AtomicBoolean、AtomicIntege、AtomicLong和AtomicReference等。
2)使用synchronized
3) 使用lock - 2.保证可见性和有序性的方法是使用volatile关键字修饰变量。
volatile 禁止指令重排序
在java里面,可以通过volatile关键字来保证一定的“有序性”。另外,可以通过synchronized和lock来保证有序性,
很显然synchronized和lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性。这个通常也成为happens-before原则。
如果两个操作的执行次序无法从happens-before原则推导出来,那么就不能保证它们的有序性,虚拟机可以随意的对它们进行重排序。
happens-before原则(优先发生原则):
- 程序次序原则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。
- 锁定原则:一个unlock操作先行发生于后面对同一个锁的lock操作。
- volatile变量原则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作。(在不违背程序结果的情况下,注意这些规则针对的是单线程)
- 传递原则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
- 线程启动原则:Thread对象的start()方法先行发生于此线程的每一个操作。
- 线程中断原则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测。
- 对象终结规则:一个对象的初始化完成先行发生于它的finalize()方法的开始。
解析:
- 程序次序规则:一段代码在单线程中执行的结果是有序的。
- 锁定规则:无论在单线程,还是在多线程环境,一个锁处于锁定状态,那么必须先执行unlock操作才能进行lock操作。
7.多线程内存模型JMM
JMM(Java Memory Model)是JVM定义的内存模型,用来屏蔽各种硬件和操作系统的内存访问差异。
- 主内存:所有的变量都存储在主内存中。
- 工作内存:每条线程都有自己的工作内存,线程的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。
不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
8.深入剖析volatile关键字
1)volatile关键字的两层含义:
- 1.一个线程修改了某个变量的值,这新值对其他线程是立即可见的。
- 2.禁止进行指令重排序。
2)volatile保证原子性:
public class Test{
public volatile int inc = 0;
public void increase(){
inc++;
}
public static void main(String[] args){
final Test test = new Test();
for(int i = 0; i < 10; i++){
new Thread(){
public void run(){
for(int j = 0; j < 1000; j++){
test.increase();
}
}
}.start();
}
while(Thread.activeCount > 1){
Thread.yield();
}
sysout(test.inc);
}
}
运行它会发现每次运行结果都不一致,都是一个小于10000的数字。
假设某个时刻变量inc的值为10.
线程1对变量进行自增操作,线程1先读取了inc的原始值,然后线程1被阻塞。
然后线程2对变量进行自增操作,线程2也去读取变量inc的原始值,由于线程1只是对变量inc进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量inc的缓存无效,所以线程2会直接去主存读取inc的值,10,加1,11,写入工作内存,写入主存。
然后线程1接着进行加1操作,由于已经读取了inc的值,注意此时在线程1的工作内存中inc的值仍然为10,加1,11写入工作内存,写入主存。
那么两个线程分别进行了一次自增操作后,inc只增加1。
根源就在这里,自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。
- 解决方法1:synchronized
public synchronized void increase(){
inc++;
}
- 解决办法2:lock
Lock lock = new ReentrantLock();
public void increase(){
lock.lock();
try: inc++;
finally: lock.unlock();
}
- 解决办法3:AutomicInteger
public AutomicInteger inc = new AutomicInteger();
public void increase(){
inc.getAndIncrement();
}
j.u.c.atomic包下提供了一些院子操作类,即对基本数据类型的自增、自减、加法、减法等进行了封装,保证这些操作是原子性操作,atomic是利用CAS(Compare And Swap)来实现原子性操作的。
CAS实际上是利用处理器提供的CMPXCHG指令实现的,而处理器执行CMPXCHG指令是原子性的。
3)volatile能保证有序性吗?
volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。
volatile关键字禁止重排序有两层意思:
-
1.当程序执行到volatile变量的读操作或写操作时,在其前面的操作的更改肯定已经全部进行,且结果已经对后面的操作可见,在其后面的操作肯定还没有进行。
-
2.在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把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的执行结果对语句3、语句4、5是可见的。
9.volatile关键字实现原理
9.1 volatile保证可见性
cpu缓存:
cpu缓存的出现主要是为了解决cpu运算速度与内存读取(写)速度不匹配的矛盾,因为cpu运算速度要比内存读写速度快得多。举个例子:
- 一次主内存的访问速度通常在几十到几百个时钟周期。
- 一次L1高速缓存的读写只需要1-2个时钟周期。
- 一次L2高速缓存的读写也只需要数十个时钟周期。
这种访问速度的显著差异,导致CPU可能花费很长时间等待数据或把数据写入内存。
基于此,现代CPU大多数情况下读写都不会直接访问内存,取而代之的是CPU缓存,CPU缓存是位于CPU与内存之间的临时数据,它的容量比内存小得多但是交换速度却比内存快得多。
缓存中的数据是内存的一小部分数据,但这一小部分是短时间内CPU即将访问的,当CPU调用大量数据时,就可先从缓存中读取,从而加快读取速度。
按照读取顺序与CPU结合的紧密程度,CPU缓存可分为:
- 一级缓存,L1 Cache,位于CPU内核的旁边,是与CPU最为紧密的CPU缓存。
- 二级缓存,L2 Cache
- 三级缓存,L3 Cache
每一级缓存中所存储的数据全部都是下一级缓存中的一部分。这三种缓存的技术难度和制造成本是相对递减,所以其容量也相对递增。
当CPU要读取一个数据时,首先从一级缓存中查找,如果没有再从二级缓存中查找,如果还是没有再从三级缓存或内存中查找。
当系统运行时,cpu执行计算过程如下:
- 1.程序以及数据被加载到内存。
- 2.指令和数据被加载到CPU缓存。
- 3.CPU执行指令,把结果写到高速缓存。
- 4.高速缓存中的数据写回主内存
如果服务器是单核CPU,那么这些步骤不会有任何问题,如果是多核CPU。
试想下面一种情况:
- 1.核0读取了1个字节,根据局部性原理,相邻的字节同样被读入核0缓存
- 2.核3做了同样的操作,这样核0与核3的缓存拥有相同的数据
- 3.核0修改了那个字节,被修改后,那个字节被写回核0的缓存,但是并没有写入主存。
- 4.核3访问该字节,由于核0并未将数据写回主存,数据不同步。
为了解决这一问题,CPU制造商规定了缓存一致性协议(MESI)
9.2 总线锁(Bus Locking)
一种处理一致性问题的办法就是使用Bus Locking(总线锁)。当一个CPU对其缓存中的数据进行操作的时候,往总线发送一个Lock信号。这个时候,所有CPU收到这个信号之后就不操作自己缓存中对应的数据了,当操作结束后,释放锁以后,所有的CPU就去内存中获取最新数据更新。——性能问题
9.3 MESI协议
MESI 是保持一致性的协议,它的方法是在CPU缓存中保存一个标志位,这个标志位有4中状态:
- M:Modify,修改缓存,当前CPU的缓存已经被修改了,即与内存中的数据不一致了
- E:Exclusive,独占缓存,当前CPU的缓存和内存中的一致,而且其他处理器并没有可使用的缓存数据。
- S:Share,共享缓存,和内存保持一致的内存拷贝,多组缓存可以同时拥有针对同一内存地址的共享缓存段。
- I:Invalid,失效缓存,这个说明CPU中的缓存已经不能被使用了。
CPU读取遵循以下几点:
- 如果缓存状态是I,那么就从内存中读取。否则就从缓存中直接读取。
- 如果缓存处于M或E的CPU读取到其他CPU有读操作,就把自己的缓存写入到内存中,并将自己的状态设为S
- 只有缓存状态是M或E的状态,CPU才可以修改缓存中的数据,修改后,缓存状态为M。
9.4 volatile保证可见性的底层原理:
instance = new Singleton(); // instance是volatile变量
// 反编译
movb $0x0
lock add1 $0x0
lock前缀的指令在多核处理器下会引发两件事情:
- 1.将当前处理器缓存行的数据写回到系统内存。
- 2.这个写回内存的操作会使其他CPU里缓存了该内存地址的数据无效。