Java并发总结
Posted Icedzzz
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java并发总结相关的知识,希望对你有一定的参考价值。
文章目录
线程和进程的区别
- 进程是程序的⼀次执⾏过程,是系统运⾏程序的基本单位,是系统进行资源分配和调度的基本单位
- 线程则是进程的一个执行路径(实体),一个进程中至少有一个线程,进程中的多个线程共享进程的资源
- 操作系统在分配资源时是把资源分配给进程的,但是 CPU 资源比较特殊,它是被分配到线程的,因为真正要占用 CPU 运行的是线程,所以也说线程是 CPU 分配的基本单位。
- 一个进程中有多个线程,多个线程共享进程的堆和方法区资源,但是每个线程有自己的程序计数器和栈区域
- 线程和进程最⼤的不同在于基本上各进程是独⽴的,⽽各线程则不⼀定,因为同⼀进程中的线程极有可能会相互影响。线程执⾏开销⼩,但不利于资源的管理和保护;⽽进程正相反
- 虚拟机栈、本地方法栈和程序计数器都是线程私有的,堆和方法区是所有线程共享的;
- 程序计数器私有主要是为了线程切换后能恢复到正确的执⾏位置,所以是线程私有的;是为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地⽅法栈是线程私有的。
线程的生命周期
Java 线程在运⾏的⽣命周期中的指定时刻只可能处于下⾯ 6 种不同状态的其中⼀个状态
面试题:为什么我们调⽤ start() ⽅法时会执⾏ run() ⽅法,为什么我们不能直接调⽤ run() ⽅法?
调⽤ start() ⽅法⽅可启动线程并使线程进⼊就绪状态,直接执⾏ run() ⽅法的话不会以多线程的⽅式执⾏,会把 run()⽅法当成⼀个 main 线程下的普通⽅法去执⾏。
并发和并行的区别
- 并发是指同一个时间段内多个任务同时都在执行,并且都没有执行结束
- 并行是说在单位时间内多个任务同时在执行
- 并发任务强调在一个时间段内同时执行,而一个时间段由多个单位时间累积而成,所以说并发的多个任务在单位时间内不一定同时在执行
wait和sleep的区别
- wait方法会释放该共享对象获得的锁,该调用线程会被阻塞挂起,直到发生调用了该共享对象的 notify() 或者 notifyAll() 方法或者调用了该共享对象的 notify() 或者 notifyAll() 方法
- wait方法为什么要释放锁:如果不释放,由于其他生产者线程和所有消费者线程都已经被阻塞挂起,而这就处于了死锁状态
- 当一个执行中的线程调用了 Thread 的 sleep 方法后,调用线程会暂时让出指定时间的执行权,也就是在这期间不参与 CPU 的调度,但是该线程所拥有的监视器资源
- 即wait方法会释放锁,而sleep会抱着锁睡
- **虚假唤醒:**一个线程可以从挂起状态变为可以运行状态(也就是被唤醒),即使该线程没有被其他线程调用 notify()、 notifyAll() 方法进行通知,或者被中断,或者等待超时
- 如何避免虚假唤醒: 做法就是不停地去测试该线程被唤醒的条件是否满足,不满足则继续等待,也就是说在一个循环中调用 wait() 方法进行防范
yield和join的区别
- join方法:当线程中调用另一一个线程的join方法,会将该线程挂起,而不是忙等待,直到目标线程结束
- yield方法:当一个线程调用 yield 方法时,当前线程会让出 CPU 使用权,然后处于就绪状态,线程调度器会从线程就绪队列里面获取一个线程优先级最高的线程,当然也有可能会调度到刚刚让出 CPU 的那个线程来获取 CPU 执行权。
- 操作系统是为每个线程分配一个时间片来占有 CPU 的,正常情况下当一个线程把分配给自己的时间片使用完后,线程调度器才会进行下一轮的线程调度,而当一个线程调用了 Thread 类的静态方法 yield 时,是在告诉线程调度器自己占有的时间片中还没有使用完的部分自己不想使用了,这暗示线程调度器现在就可以进行下一轮的线程调度
线程死锁
- 死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去
- 死锁产生的四个条件:
- **互斥条件 :**指线程对已经获取到的资源进行排它性使用,即该资源同时只由一个线程占用。
- 请求并持有条件:指一个线程已经持有了至少一个资源,但又提出了新的资源请求,而新资源已被其他线程占有,所以当前线程会被阻塞,但阻塞的同时并不释放自己已经获取的资源。
- 不可剥夺条件 :指线程获取到的资源在自己使用完之前不能被其他线程抢占,只有在自己使用完毕后才由自己释放该资源。
- 环路等待条件 :指在发生死锁时,必然存在一个线程—资源的环形链,即线程集合T0, T1, T2,…, Tn 中的 T0 正在等待一个 T1 占用的资源, T1 正在等待 T2 占用的资源,……Tn 正在等待已被 T0 占用的资源。
- 如何避免死锁:破坏请求并持有和环路等待条件。即保证线程资源申请的有序性即可。
守护线程与用户线程
- 用户线程:平时用到的普通线程均是用户线程
- 守护线程:指在程序运行的时候在后台提供一种通用服务的线程,守护线程是为用户线程服务的,当有用户线程在运行,那么守护线程同样需要工作,当所有的用户线程都结束时,守护线程也就会停止
- 守护线程是依赖于用户线程,用户线程退出了,守护线程也就会退出,典型的守护线程如垃圾回收线程。
线程同步
线程安全问题: 线程安全问题是指当多个线程同时读写一个共享资源并且没有任何同步措施时,导致出现脏数据或者其他不可预见的结果的问题,这个时候需要用到线程同步。
线程同步: 多个线程并发访问资源时,保证共享资源发生在同一时刻只被一个线程。同步是一种等待机制,多个需要同时访问此对象的线程进入这个对象的等待池形成队列,前面的使用完毕,下一个线程再使用,线程同步执行条件:队列 + 锁
解决线程同步的手段有两种:互斥同步和非堵塞同步(悲观与乐观)
并发编程的三个重要特性
- 原⼦性 : ⼀个的操作或者多次操作,要么所有的操作全部都得到执⾏并且不会收到任何因素的⼲扰⽽中断,要么所有的操作都执⾏,要么都不执⾏。 synchronized 可以保证代码⽚段的原⼦性。
- **可⻅性 :**当⼀个变量对共享变量进⾏了修改,那么另外的线程都是⽴即可以看到修改后的最新值。 volatile 关键字可以保证共享变量的可⻅性。synchronized和final同样可以保证可见性。
- **有序性 :**在本线程内观察所有操作都是有序的(线程内表现为串行的语义),另外一个线程观察此线程所有操作都是无序的(指令重排序) 。 volatile 关键字可以禁⽌指令进⾏重排序优化。
synchronized 关键字
-
synchronized 块是 Java 提供的一种原子性内置锁, Java 中的每个对象都可以把它当作一个同步锁来使用,这些 Java 内置的使用者看不到的锁被称为内部锁,也叫作监视器锁。线程的执行代码在进入 synchronized 代码块前会自动获取内部锁,这时候其他线程访问该同步代码块时会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者抛出异常后或者在同步块内调用了该内置锁资源的 wait 系列方法时释放该内置锁。内置锁是排它锁,也就是当一个线程获取这个锁后,其他线程必须等待该线程释放锁后才能获取该锁
-
**使用synchronized带来的问题:**由于 Java 中的线程是与操作系统的原生线程一一对应的,所以当阻塞一个线程时,需要从用户态切换到内核态执行阻塞操作,这是很耗时的操作,而 synchronized 的使用就会导致上下文切换,并带来线程调度开销。并且一个线程持有锁会导致其他所有需要此锁的线程挂起。如果一个优先级高的线程等待一个优先级低的线程释放锁,会导致性能倒置,引起性能问题。
-
synchronized 的一个内存语义:这个内存语义就可以解决共享变量内存可见性问题。进入 synchronized 块的内存语义是把在 synchronized 块内使用到的变量从线程的工作内存中清除,这样在 synchronized 块内使用到该变量时就不会从线程的工作内存中获取,而是直接从主内存中获取。退出 synchronized 块的内存语义是把在 synchronized 块内对共享变量的修改刷新到主内存
讲⼀下 synchronized 关键字的底层原理
- synchronized 同步语句块的实现使⽤的是 monitorenter 和 monitorexit 指令,其中 monitorenter指令指向同步代码块的开始位置, monitorexit 指令则指明同步代码块的结束位置。
当执⾏monitorenter 指令时,线程试图获取锁也就是获取 **monitor(monitor对象存在于每个Java对象的对象头中, synchronized 锁便是通过这种⽅式获取锁的,也是为什么Java中任意对象可以作为锁的原因)**的持有权。当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执⾏monitorexit 指令后,将锁计数器设为0,表明锁被释放。 - synchronized 修饰的⽅法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是ACC_SYNCHRONIZED 标识,该标识指明了该⽅法是⼀个同步⽅法, JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别⼀个⽅法是否声明为同步⽅法,从⽽执⾏相应的同步调⽤。
JDK1.6之后synchronized 关键字底层做了哪些优化:
JDK1.6 对锁的实现引⼊了⼤量的优化,如偏向锁、轻量级锁、⾃旋锁、适应性⾃旋锁、锁消除、锁粗化等技术来减少锁操作的开销。锁主要存在四种状态,依次是:⽆锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈⽽逐渐升级。注意锁可以升级不可降级,这种策略是为了提⾼获得锁和释放锁的效率。
理解上下文切换
在多线程编程中,线程个数一般都大于 CPU 个数,而每个 CPU 同一时刻只能被一个线程使用,为了让用户感觉多个线程是在同时执行的, CPU 资源的分配采用了时间片轮转的策略,也就是给每个线程分配一个时间片,线程在时间片内占用 CPU 执行任务。当前线程使用完时间片后,就会处于就绪状态并让出 CPU 让其他线程占用,这就是上下文切换,从当前线程的上下文切换到了其他线程。那么就有一个问题,让出 CPU 的线程等下次轮到自己占有 CPU 时如何知道自己之前运行到哪里了?所以在切换线程上下文时需要保存当前线程的执行现场,当再次执行时根据保存的执行现场信息恢复执行现场。
- 线程上下文切换时机有 : 当前线程的 CPU 时间片使用完处于就绪状态时,当前线程被其他线程中断时。
单例模式
public class Singleton
private volatile static Singleton uniqueInstance;
private Singleton()
public synchronized static Singleton getUniqueInstance()
//先判断对象是否已经实例过,没有实例化过才进⼊加锁代码
if (uniqueInstance WX null)
//类对象加锁
synchronized (Singleton.class)
if (uniqueInstance WX null)
uniqueInstance = new Singleton();
return uniqueInstance;
- 为什么需要两次判断if(singleTon==null)?
-
**第一次校验:**由于单例模式只需要创建一次实例,如果后面再次调用getInstance方法时,则直接返回之前创建的实例,因此大部分时间不需要执行同步方法里面的代码,大大提高了性能。如果不加第一次校验的话,那跟上面的懒汉模式没什么区别,每次都要去竞争锁。
-
**第二次校验:**如果没有第二次校验,假设线程t1执行了第一次校验后,判断为null,这时t2也获取了CPU执行权,也执行了第一次校验,判断也为null。接下来t2获得锁,创建实例。这时t1又获得CPU执行权,由于之前已经进行了第一次校验,结果为null(不会再次判断),获得锁后,直接创建实例。结果就会导致创建多个实例。所以需要在同步代码里面进行第二次校验,如果实例为空,则进行创建。
- 为什么要用voitilote修饰?
Instance 采⽤ volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton()
这段代码其实是分为三步执⾏:
- 为 uniqueInstance 分配内存空间
- 初始化 uniqueInstance
- 将 uniqueInstance 指向分配的内存地址
但是由于 JVM 具有指令重排的特性,执⾏顺序有可能变成 1i>3i>2。指令重排在单线程环境下不会出
现问题,但是在多线程环境下会导致⼀个线程获得还没有初始化的实例。例如,线程 T1 执⾏了 1 和
3,此时 T2 调⽤ getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回
uniqueInstance,但此时 uniqueInstance 还未被初始化。
使⽤ volatile 可以禁⽌ JVM 的指令重排,保证在多线程环境下也能正常运⾏
ReentrantLock
从JDK5.0开始,Java提供了通过显示定义同步锁对象来实现同步,并通过ReentrantLock类实现了Lock,它拥有与synchronized想通的并发性和内存语义,在实现线程安全的控制中,比较常用的是ReentrantLock,可以显式加锁、释放锁
二者的区别
- Synchronized 内置的Java关键字, Lock 是一个Java类
- Synchronized 无法判断获取锁的状态,Lock 可以判断是否获取到了锁
- Synchronized 会自动释放锁,lock 必须要手动释放锁!如果不释放锁,死锁
- Synchronized 线程 1(获得锁,阻塞)、线程2(等待,傻傻的等);Lock锁就不一定会等待下去;
- Synchronized 可重入锁,不可以中断的,非公平;Lock ,可重入锁,可以 判断锁,非公平(可以自己设置);
- Synchronized 适合锁少量的代码同步问题,Lock 适合锁大量的同步代码!
非堵塞同步
采用锁的方式解决同步问题也称为互斥同步或堵塞同步,属于一种悲观的并发策略。
这里还有另外一种选择:非堵塞同步 ,基于冲突检测的乐观并发策略。
对共享资源进行操作,如果没有其他线程争用数据,那操作就成功了;
如果共享数据有争用,产生了冲突,那就采用其他的补偿策略(自旋:不断重试,直到成功),这种并发策略不需要挂起线程。
常用的指令为CAS
生成者消费组问题
这是一个线程同步问题,生产者和消费者共享一个资源,并且生产者和消费者之间互为依赖,互为条件
- 对于生产者,没有生产产品前,通知消费者等待,生产产品后,通知消费者消费
- 对于消费者,消费之后要通知生产者已经结束消费,需要生产新的产品提供消费
- 在生产者消费者问题中,仅有synchronized是不够的
- synchronized可阻止并发更新同一个共享资源,实现了同步
- synchronized不能用来实现线程之间的通讯
ThreadLocal
- 多线程访问同一个共享变量时特别容易出现并发问题,特别是在多个线程需要对一个共享变量进行写入时。为了保证线程安全,一般使用者在访问共享变量时需要进行适当的同步。
- ThreadLocal 是 JDK 包提供的,它提供了线程本地变量,也就是如果你创建了一ThreadLocal 变量,那么访问这个变量的每个线程都会有这个变量的一个本地副本。当多个线程操作这个变量时,实际操作的是自己本地内存里面的变量,从而避免了线程安全问题。
- ThreadLocal的作用主要是做数据隔离,填充的数据只属于当前线程,变量的数据对别的线程而言是相对隔离的,在多线程环境下,如何防止自己的变量被其它线程篡改。
- 举例: Spring采用Threadlocal的方式,来保证单个线程中的数据库操作使用的是同一个数据库连接,同时,采用这种方式可以使业务层使用事务时不需要感知并管理connection对象,通过传播级别,巧妙地管理多个事务配置之间的切换,挂起和恢复。
ThreadLocal实现原理
Thread 类中有一个 threadLocals 和一个 inheritableThreadLocals,它们都是 ThreadLocalMap 类型的变量,而 ThreadLocalMap 是一个定制化的 Hashmap,其中 key 为我们定义的 ThreadLocal 变量的 this 引用, value 则为我们使用 set 方法设置的值。在默认情况下,每个线程中的这两个变量都为 null,只有当前线程第一次调用 ThreadLocal 的 set 或者 get 方法时才会创建它们。其实每个线程的本地变量不是存放在 ThreadLocal 实例里面,而是存放在调用线程的 threadLocals 变量里面。也就是说, ThreadLocal 类型的本地变量存放在具体的线程内存空间中。 ThreadLocal 就是一个工具壳,它通过 set 方法把 value 值放入调用线程的 threadLocals 里面并存放起来,当调用线程调用它的 get 方法时,再从当前线程的 threadLocals 变量里面将其拿出来使用。如果调用线程一直不终止,那么这个本地变量会一直存放在调用线程的 threadLocals 变量里面,可能会造成内存溢出,因
此使用完毕后要记得调用 ThreadLocal 的 remove 方法删除对应线程的 threadLocals 中的本地变量。
public void set(T value)
//(1)获取当前线程
Thread t = Thread.currentThread();
//(2)将当前线程作为key,去查找对应的线程变量
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
//(3)第一次调用就创建当前线程对应的HashMap
createMap(t, value);
ThreadLocalMap getMap(Thread t)
return t.threadLocals;
每个线程Thread都维护了自己的threadLocals变量,所以在每个线程创建ThreadLocal的时候,实际上数据是存在自己线程Thread的threadLocals变量里面的,别人没办法拿到,从而实现了隔离。
public T get()
//(4) 获取当前线程
Thread t = Thread.currentThread();
//(5)获取当前线程的threadLocals变量
ThreadLocalMap map = getMap(t);
//(6)如果threadLocals不为null,则返回对应本地变量的值
if (map != null)
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null)
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
//(7)threadLocals为空则初始化当前线程的threadLocals成员变量
return setInitialValue();
- ThreadLocal 不支持继承性:同一个 ThreadLocal 变量在父线程中被设置值后,在子线程中是获取不到的。
- InheritableThreadLocal可以让子线程可以访问在父线程中设置的本地变量。使用InheritableThreadLocal可以实现多个线程访问ThreadLocal的值,我们在主线程中创建一个InheritableThreadLocal的实例,然后在子线程中得到这个InheritableThreadLocal实例设置的值。
private void test()
final ThreadLocal threadLocal = new InheritableThreadLocal();
threadLocal.set("xx");
Thread t = new Thread()
@Override
public void run()
super.run();
Log.i( threadLocal.get());
;
t.start();
-
如果线程的inheritThreadLocals变量不为空,而且父线程的inheritThreadLocals也存在,那么我就把父线程的inheritThreadLocals给当前线程的inheritThreadLocals。
-
ThreadLocalMap没有实现Map接口,而且他的Entry是继承**WeakReference(弱引用)**的,也没有看到HashMap中的next,所以不存在链表了
-
**弱引用:**只具有弱引用的对象拥有更短暂的生命周期,在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。 不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。
-
如果发生Hash碰撞问题,即key不等于entry,(开放地址法)那就找下一个空位置,直到为空为止
-
ThreadLocal是被其创建的类持有(更顶端应该是被线程持有),而ThreadLocal的值其实也是被线程实例持有,它们都是位于堆上,只是通过一些技巧将可见性修改成了线程可见。
-
内存泄漏: ThreadLocal在没有外部强引用时,发生GC时会被回收(key是弱引用,被直接回收 ),如果创建ThreadLocal的线程一直持续运行,那么这个Entry对象中的value就有可能一直得不到回收,发生内存泄露。就比如线程池里面的线程,线程都是复用的,那么之前的线程实例处理完之后,出于复用的目的线程依然存活,所以,ThreadLocal设定的value值被持有,导致内存泄露。(解决方法remove)
内存可见性问题
问题描述
- Java 内存模型规定,将所有的变量都存放在主内存中,当线程使用变量时,会把主内存里面的变量复制到自己的工作空间或者叫作工作内存,线程读写变量时操作的是自己工作内存中的变量。
- JMM是抽象的概念,在实际内存结构中,CPU每个核有自己的控制器和运算器,其中控制器包含一组寄存器和操作控制器,运算器执行算术逻辑运算。每个核都有自己的一级缓存,在有些架构里面还有一个所有 CPU 都共享的二级缓存。那么 Java 内存模型里面的工作内存,就对应这里的 L1 或者 L2 缓存或者 CPU 的寄存器。
内存不可见问题:引发原因,Cache的存在,线程首先会从一级/二级缓存中查看是否存在需要读取的数值,如果不存在才会去主内存中读取,如果一个线程对变量进行了修改,另外一个线程中这个值并为从主内存中读取,而缓存中该指大小是以及被修改了的,因此则会产生内存不可见问题
- 线程 A 首先获取共享变量 X 的值,由于两级 Cache 都没有命中,所以加载主内存中 X 的值,假如为 0。然后把 X=0 的值缓存到两级缓存,线程 A 修改 X 的值为 1,然后将其写入两级 Cache,并且刷新到主内存。线程 A 操作完毕后,线程 A 所在的CPU 的两级 Cache 内和主内存里面的 X 的值都是 1。
- 线程 B 获取 X 的值,首先一级缓存没有命中,然后看二级缓存,二级缓存命中了,所以返回 X= 1 ;到这里一切都是正常的,因为这时候主内存中也是 X=1。然后线程 B 修改 X 的值为 2,并将其存放到线程 2 所在的一级 Cache 和共享二级 Cache 中,最后更新主内存中 X 的值为 2 ;到这里一切都是好的
- 线程 A 这次又需要修改 X 的值,获取时一级缓存命中,并且 X=1,到这里问题就出现了,明明线程 B 已经把 X 的值修改为了 2,为何线程 A 获取的还是 1 呢?这就是共享变量的内存不可见问题,也就是线程 B 写入的值对线程 A 不可见。
volatile
https://ifeve.com/java-volatile%e5%85%b3%e9%94%ae%e5%ad%97/#more-45241
- synchronized可以解决内存不可见问题,但synchronized是重量级锁,会为线程带来上下文的切换开销
- volatile是java虚拟机提供的最轻量级的同步机制,该关键字可以确保对一个变量的更新对其他线程马上可见。当一个变量被声明为volatile 时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。
- volatile 虽然提供了可见性保证,但并不保证操作的原子性
- 使用volatile的场景:
- 写入变量值不依赖变量的当前值时。因为如果依赖当前值,将是获取—计算—写入三步操作,这三步操作不是原子性的,而 volatile 不保证原子性。
- 读写变量值时没有加锁。
指令重排
Java 内存模型允许编译器和处理器对指令重排序以提高运行性能,并且只会对不存在数据依赖性的指令重排序。在单线程下重排序可以保证最终执行的结果与程序顺序执行的结果一致,但是在多线程下就会存在问题。
-
指令重排是指CPU采用允许将多条指令不按程序的顺序分开发送给各相应电路处理单元
-
volatile 的另外一语义是禁止指令重排优化
-
写 volatile 变量时,可以确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。读 volatile 变量时,可以确保 volatile 读之后的操作不会被编译器重排序到 volatile读之前。
-
如将代码编译成JIT的汇编代码,可以看到用volatile修饰的变量,赋值后语句后会多执行一个"lock…"操作,找个操作就相当于一个内存屏障,重排序时不能把后面的指令重排序到内存屏障之前。同时lock指令,也意味着所以之前的操作都已经执行完成。
happens-before
参考:https://www.cnblogs.com/chenssy/p/6393321.html
从JDK 5 开始,JMM就使用happens-before的概念来阐述多线程之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。 happens-before原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们解决在并发环境下两操作之间是否可能存在冲突的所有问题。
happens-before原则定义如下:
-
如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
-
两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。
下面是happens-before原则规则:
- 程序次序规则:一段代码在单线程中执行的结果是有序的。注意是执行结果,因为虚拟机、处理器会对指令进行重排序(重排序后面会详细介绍)。虽然重排序了,但是并不会影响程序的执行结果,所以程序最终执行的结果与顺序执行的结果是一致的。故而这个规则只对单线程有效,在多线程环境下无法保证正确性。
- 锁定规则:这个规则比较好理解,无论是在单线程环境还是多线程环境,一个锁处于被锁定状态,那么必须先执行unlock操作后面才能进行lock操作。
- volatile变量规则:这是一条比较重要的规则,它标志着volatile保证了线程可见性。通俗点讲就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作一定是happens-before读操作的。
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
- 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;
CAS
CAS 即 Compare and Swap,其是 JDK 提供的非阻塞原子性操作,它通过硬件保证了比较—更新操作的原子性。JDK 里面的 Unsafe 类提供了一系列的compareAndSwap* 方法
- CAS 有四个操作数,分别为 :对象内存位置、对象中的变量的偏移量、变量预期值和新的值。
- 操作含义是,如果对象 obj 中内存偏移量为 valueOffset 的变量值为 expect,则使用新的值 update 替换旧的值 expect。
- ABA问题:假如线程 I 使用 CAS 修改初始值为 A 的变量 X,那么线程 I 会首先去获取当前变量 X 的值(为 A),然后使用 CAS 操作尝试修改 X 的值为 B,如果使用 CAS 操作成功了,那么程序运行一定是正确的吗?其实未必,这是因为有可能在线程 I 获取变量 X 的值 A 后,在执行 CAS 前,线程 II 使用 CAS 修改了变量 X 的值为 B,然后又使用 CAS 修改了变量 X 的值为 A。所以虽然线程 I 执行 CAS时 X 的值是 A,但是这个 A 已经不是线程 I 获取时的 A 了
- 为了解决ABA问题:JDK 中的 AtomicStampedReference 类给每个变量的状态值都配备了一个时间戳,从而避免了 ABA 问题的产生。
Unsafe类
JDK 的 rt.jar 包中的 Unsafe 类提供了硬件级别的原子性操作, Unsafe 类中的方法都是native 方法,它们使用 JNI 的方式访问本地 C++ 实现库。
一些代表性的api
compareAndSwapLong(Object obj, long offset, long expect, long update)
void park(boolean isAbsolute, long time) 方法 : 阻塞当前线程,
void unpark(Object thread) 方法 :唤醒调用 park 后阻塞的线程
ong getAndSetLong(Object obj, long offset, long update) 方法 :获取对象 obj 中偏移
量为 offset 的变量 volatile 语义的当前值,并设置变量 volatile 语义的值为 update。
- Unsafe 类是 rt.jar 包提供的, rt.jar 包里面的类是使用 Bootstrap 类加载器加载的,而我们的启动 main 函数所在的类是使用AppClassLoader 加载的,所以在 main 函数里面加载 Unsafe 类时,根据委托机制,会委托给 Bootstrap 去加载 Unsafe 类。
伪共享
伪共享的非标准定义为:当 CPU 访问某个变量时,首先会去看 CPU Cache 内是否有该变量,如果有则直接从中获取,否则就去主内存里面获取该变量,然后把该变量所在内存区域的一个 Cache 行大小的内存复制到 Cache 中。由于存放到 Cache 行的是内存块而不是单个变量,所以可能会把多个变量存放到一个 Cache 行中。当多个线程同时修改一个缓存行里面的多个变量时,由于同时只能有一个线程操作缓存行,所以相比将每个变量放到一个缓存行,性能会有所下降,这就是伪共享
如何避免伪共享
在 JDK 8 之前一般都是通过字节填充的方式来避免该问题,也就是创建一个变量时使用填充字段填充该变量所在的缓存行,这样就避免了将多个变量存放在同一个缓存行中,
public final static class FilledLong
public volatile long value = 0L;
public long p1, p2, p3, p4, p5, p6;
假如缓存行为 64 字节,那么我们在 FilledLong 类里面填充了 6 个 long 类型的变量,每个 long 类型变量占用 8 字节,加上 value 变量的 8 字节总共 56 字节。另外,这里FilledLong 是一个类对象,而类对象的字节码的对象头占用 8 字节,所以一个 FilledLong对象实际会占用 64 字节的内存,这正好可以放入一个缓存行
JDK 8 提供了一个 sun.misc.Contended 注解,用来解决伪共享问题。将上面代码修改为如下。
@sun.misc.Contended
public final static class FilledLong
public volatile long value = 0L;
锁
- 悲观锁指对数据被外界修改持保守态度,认为数据很容易就会被其他线程修改,所以在数据被处理前先对数据进行加锁,并在整个数据处理过程中,使数据处于锁定状态
- 乐观锁是相对悲观锁来说的,它认为数据在一般情况下不会造成冲突,所以在访问记录前不会加排它锁,而是在进行数据提交更新时,才会正式对数据冲突与否进行检测。
- 根据线程获取锁的抢占机制,锁可以分为公平锁和非公平锁
- 公平锁表示线程获取锁的顺序是按照线程请求锁的时间早晚来决定的,也就是最早请求锁的线程将最早获取到锁
- 非公平锁则在运行时闯入,也就是先来不一定先得
- ReentrantLock 提供了公平和非公平锁的实现;ReentrantLock pairLock = new ReentrantLock(false)为非公平锁。 如果构造函数不传递参数,则默认是非公平锁
- 在没有公平性需求的前提下尽量使用非公平锁,因为公平锁会带来性能开销。
- 根据锁只能被单个线程持有还是能被多个线程共同持有,锁可以分为独占锁和共享锁
- 独占锁保证任何时候都只有一个线程能得到锁, ReentrantLock 就是以独占方式实现的,独占锁是一种悲观锁
- 共享锁则可以同时由多个线程持有,例如 ReadWriteLock 读写锁,它允许一个资源可以被多线程同时进行读操作,共享锁则是一种乐观锁,它放宽了加锁的条件,允许多个线程同时进行读操作。
- **可重入锁:**当一个线程再次获取它自己已经获取的锁时,如果不被阻塞,那么我们说该锁是可重入的,也就是只要该线程获取了该锁,那么可以无限次数地进入被该锁锁住的代码。
- synchronized 内部锁是可重入锁
- 可重入锁的原理:锁在内部维护一个线程标示,用来标示该锁目前被哪个线程占用,然后关联一个计数器。一开始计数器值为 0,说明该锁没有被任何线程占用。当一个线程获取了该锁时,计数器的值会变成 1,这时其他线程再来获取该锁时会发现锁的所有者不是自己而被阻塞挂起。但是当获取了该锁的线程再次获取锁时发现锁拥有者是自己,就会把计数器值加 +1,当释放锁后计数器值 -1。当计数器值为 0 时,锁里面的线程标示被重置为 null,这时候被阻塞的线程会被唤醒来竞争获取该锁。
- 自旋锁: 当前线程在获取锁时,如果发现锁已经被其他线程占有,它不马上阻塞自己,在不放弃 CPU 使用权的情况下,多次尝试获取(默认次数是 10,可以使用 -XX:PreBlockSpinsh 参数设置该值),很有可能在后面几次尝试中其他线程已经释放了锁。如果尝试指定的次数后仍没有获取到锁则当前线程才会被阻塞挂起
- 为什么要自旋锁: 由于 Java 中的线程是与操作系统中的线程一一对应的,所以当一个线程在获取锁(比如独占锁)失败后,会被切换到内核状态而被挂起。当该线程获取到锁时又需要将其切换到内核状态而唤醒该线程。而从用户状态切换到内核状态的开销是比较大的,在一定程度上会影响并发性能。
java原子变量操作类
1.AtomicLong
JUC 包提供了一系列的原子性操作类,这些类都是使用非阻塞算法 CAS 实现的,其内
部使用 Unsafe 来实现,synchronized等锁实现原子性操作在性能上会有些损耗。
以AtomicLong为例,AtomicLong 是原子性递增或者递减类,其内部使用 Unsafe 来实现:
代码如下:
- AtomicLong类也是在 rt.jar 包下面的, AtomicLong 类就是通过 BootStarp 类加载器进行加载的
public class AtomicLong extends Number implements java.io.Serializable
private static final long serialVersionUID = 1927816293512124184L;
// ( 1)获取Unsafe实例
private static final Unsafe unsafe = Unsafe.getUnsafe();
//( 2)存放变量value的偏移量
private static final long valueOffset;
//( 3)判断JVM是否支持Long类型无锁CAS
static final boolean VM_SUPPORTS_LONG_CAS = VMSupportsCS8();
private static native boolean VMSupportsCS8();
static
try
//( 4)获取value在AtomicLong中的偏移量
valueOffset = unsafe.objectFieldOffset
(AtomicLong.class.getDeclaredField("value"));
catch (Exception ex) throw new Error(ex);
//( 5)实际变量值
private volatile long value;
/**
*递增和递减操作代码
*/
//调用unsafe方法,原子性设置value值为原始值+1,返回值为原始值
public final long getAndIncrement()
return unsafe.getAndAddLong(this, valueOffset, 1L);
//调用unsafe方法,原子性设置value值为原始值-1,返回值为原始值
public final long getAndDecrement()
return unsafe.getAndAddLong(this, valueOffset, -1L);
/**
* 调用unsafe方法,原子性设置value值为原始值+1,返回值为递增后的值
*/
public final long incrementAndGet()
return unsafe.getAndAddLong(this, valueOffset, 1L) + 1L;
- Unsafe 的 getAndAddLong是个原子性操作,这里第一个参数是 AtomicLong 实例的引用,
以上是关于Java并发总结的主要内容,如果未能解决你的问题,请参考以下文章
Java并发编程小总结:CountDownLatchCyclicBarrier和Semaphore