一.悲观锁和乐观锁
悲观锁
认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。
synchronized关键字和Lock的实现类都是悲观锁
使用场景:
适合写操作多的场景,先加锁可以保证写操作时数据正确。
显式的锁定之后再操作同步资源。
乐观锁
乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。
如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作
乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。
==使用场景:==
适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
乐观锁则直接去操作同步资源,是一种无锁算法,得之我幸不得我命,再抢。
乐观锁一般有两种实现方式:采用版本号机制、CAS(Compare-and-Swap,即比较并替换)算法实现
//=============悲观锁的调用方式
//synchronized同步方法或同步代码块
public synchronized void m1(){
//加锁后的业务逻辑......
}
//保证多个线程使用的是同一个lock对象的前提下
ReentrantLock lock = new ReentrantLock();
public void m2() {
lock.lock();
try {
// 操作同步资源
}finally {
lock.unlock();
}
}
//=============乐观锁的调用方式
//保证多个线程使用的是同一个AtomicInteger原子类
private AtomicInteger atomicInteger = new AtomicInteger();
atomicInteger.incrementAndGet();
二.从字节码角度分析synchronized实现
javap -c ***.class 文件反编译
javap -v ***.class 文件反编译
-c 对代码进行反汇编
-v -verbose 输出附加信息(包括行号、本地变量表,反汇编等详细信息)
synchronized同步代码块
情况1:
public class SyncDemo1 {
public void test(){
synchronized ("A"){
System.out.println("---hello Thread---");
}
}
}
//javap -c SyncDemo1.class
synchronized实现使用的是monitorenter和monitorexit指令。
此时monitorenter和monitorexit配比是==1:2==,因为需要确保无论是正常执行程序还是出现异常,都可以释放对象锁。
情况2:
public class SyncDemo1 {
public void test(){
synchronized ("A"){
System.out.println("---hello Thread---");
throw new RuntimeException("出现异常了");
}
}
}
//javap -c SyncDemo1.class
synchronized实现使用的是monitorenter和monitorexit指令。
此时monitorenter和monitorexit配比是==1:1==,因为块中抛出异常,因为此时要告知系统是非正常中断的释放锁。
synchronized普通同步方法
public class SyncDemo1 {
public synchronized void method1(){
System.out.println("---hello Thread---");
}
}
//javap -v ***.class文件反编译
调用指令将会检查方法的==ACC_SYNCHRONIZED==访问标志是否被设置。 如果设置了,执行线程会将先持有monitor然后再执行方法, 最后在方法完成(无论是正常完成还是非正常完成)时释放 monitor。
synchronized静态同步方法
public class SyncDemo1 {
public static synchronized void method2(){
System.out.println("---hello Thread---");
}
}
//javap -v ***.class文件反编译
ACC_STATIC, ACC_SYNCHRONIZED访问标志区分该方法是否静态同步方法
反编译synchronized锁的是什么?
什么是管程?
管程 (英语:Monitors,也称为监视器) 是一种程序结构,结构内的多个子程序(对象或模块)形成的多个工作线程互斥访问共享资源。 这些共享资源一般是硬件设备或一群变量。对共享变量能够进行的所有操作集中在一个模块中。(把信号量及其操作原语“封装”在一个对象内部)管程实现了在一个时间点,最多只有一个线程在执行管程的某个子程序。管程提供了一种机制,管程可以看做一个软件模块,它是将共享的变量和对于这些共享变量的操作封装起来,形成一个具有一定接口的功能模块,进程可以调用管程来实现进程级别的并发控制。
Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(M onitor,更常见的是直接将它称为“锁”)来实现的。
方法级的同步是隐式的,无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池中的方法表结构中的==ACC_SYNCHRONIZED==访问标志得知一个方法是否被声明为同步方法。当方法调用时,调用指令将会检查万法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,==执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放管程。在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取到同一个管程。==如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的管程将在异常抛到同步方法边界之外时自动释放。
同步一段指令集序列通常是由Java语言中的synchronized语句块来表示的,Java虚拟机的指令集中有monitorenter和monitorexit两条指令来支持synchronized关键字的语义,正确实现synchronized关键字需要Javac编译器与Java虚拟机两者共同协作支持。
在HotSpot虚拟机中,monitor采用ObjectMonitor实现。
每个对象天生都带着一个对象监视器。
Java在对象的头文件存储了锁的相关信息
synchronized必须作用于某个对象中,所以Java在对象的头文件存储了锁的相关信息。锁升级功能主要依赖于 MarkWord 中的锁标志位和释放偏向锁标志位。
三.公平锁和非公平锁
import java.util.concurrent.locks.ReentrantLock;
class Ticket{
private int number = 30;
ReentrantLock lock = new ReentrantLock();
public void sale(){
lock.lock();
try{
if(number > 0){
System.out.println(Thread.currentThread().getName()+"卖出第:\t"+(number--)+"\t 还剩下:"+number);
}
}catch (Exception e){
e.printStackTrace();
}finally {
lock.unlock();
}
}
}
public class SaleTicketDemo{
public static void main(String[] args){
Ticket ticket = new Ticket();
new Thread(() -> { for (int i = 0; i <35; i++) ticket.sale(); },"a").start();
new Thread(() -> { for (int i = 0; i <35; i++) ticket.sale(); },"b").start();
new Thread(() -> { for (int i = 0; i <35; i++) ticket.sale(); },"c").start();
}
}
1. 何为公平锁/非公平锁?
⽣活中,排队讲求先来后到视为公平。程序中的公平性也是符合请求锁的绝对时间的,其实就是 FIFO,否则视为不公平。
按序排队公平锁,就是判断同步队列是否还有先驱节点的存在(我前面还有人吗?),如果没有先驱节点才能获取锁; 先占先得非公平锁,是不管这个事的,只要能抢获到同步状态就可以。
2. 为什么会有公平锁/非公平锁的设计为什么默认非公平?
1)、恢复挂起的线程到真正锁的获取还是有时间差的,从开发人员来看这个时间微乎其微,但是从CPU的角度来看,这个时间差存在的还是很明显的。所以非公平锁能更充分的利用CPU 的时间片,尽量减少 CPU 空闲状态时间。
2)、使用多线程很重要的考量点是线程切换的开销,当采用非公平锁时,==当1个线程请求锁获取同步状态,然后释放同步状态,因为不需要考虑是否还有前驱节点,所以刚释放锁的线程在此刻再次获取同步状态的概率就变得非常大,所以就减少了线程的开销。==
3. 使⽤非公平锁会有什么问题?
公平锁保证了排队的公平性,非公平锁霸气的忽视这个规则,所以就有可能导致排队的长时间在排队,也没有机会获取到锁,这就是传说中的 “锁饥饿”。
4. 什么时候用公平?什么时候用非公平?
如果为了更高的吞吐量,很显然非公平锁是比较合适的,因为节省很多线程切换时间,吞吐量自然就上去了; 否则那就用公平锁,大家公平使用。
四.可重入锁(又名递归锁)
可重入锁是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提,锁对象得是同一个对象),不会因为之前已经获取过还没释放而阻塞。
如果是1个有 synchronized 修饰的递归调用方法,程序第2次进入被自己阻塞了岂不是天大的笑话,出现了作茧自缚。所以Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。
“可重入锁”这四个字分开来解释:
可:可以。
重:再次。
入:进入。
锁:同步锁。
进入什么?
进入同步域(即同步代码块/方法或显式锁锁定的代码)
一个线程中的多个流程可以获取同一把锁,持有这把同步锁可以再次进入。
自己可以获取自己的内部锁。
可重入锁种类
隐式锁(即synchronized关键字使用的锁)默认是可重入锁。
//同步块
public class ReEntryLockDemo
{
public static void main(String[] args)
{
final Object objectLockA = new Object();
new Thread(() -> {
synchronized (objectLockA)
{
System.out.println("-----外层调用");
synchronized (objectLockA)
{
System.out.println("-----中层调用");
synchronized (objectLockA)
{
System.out.println("-----内层调用");
}
}
}
},"a").start();
}
}
//同步方法
/**
* 在一个Synchronized修饰的方法或代码块的内部调用本类的其他Synchronized修饰的方法或代码块时,是永远可以得到锁的
*/
public class ReEntryLockDemo
{
public synchronized void m1()
{
System.out.println("-----m1");
m2();
}
public synchronized void m2()
{
System.out.println("-----m2");
m3();
}
public synchronized void m3()
{
System.out.println("-----m3");
}
public static void main(String[] args)
{
ReEntryLockDemo reEntryLockDemo = new ReEntryLockDemo();
reEntryLockDemo.m1();
}
}
Synchronized的重入的实现机理
每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。
当执行monitorenter时,如果目标锁对象的计数器为零,那么说明它没有被其他线程所持有,Java虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器加1。
在目标锁对象的计数器不为零的情况下,如果锁对象的持有线程是当前线程,那么 Java 虚拟机可以将其计数器加1,否则需要等待,直至持有线程释放该锁。
当执行monitorexit时,Java虚拟机则需将锁对象的计数器减1。计数器为零代表锁已被释放。
显式锁(即Lock)也有ReentrantLock这样的可重入锁。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* 在一个Synchronized修饰的方法或代码块的内部调用本类的其他Synchronized修饰的方法或代码块时,是永远可以得到锁的
*/
public class ReEntryLockDemo
{
static Lock lock = new ReentrantLock();
public static void main(String[] args)
{
new Thread(() -> {
lock.lock();
try
{
System.out.println("----外层调用lock");
lock.lock();
try
{
System.out.println("----内层调用lock");
}finally {
// 这里故意注释,实现加锁次数和释放次数不一样
// 由于加锁次数和释放次数不一样,第二个线程始终无法获取到锁,导致一直在等待。
lock.unlock(); // 正常情况,加锁几次就要解锁几次
}
}finally {
lock.unlock();
}
},"a").start();
new Thread(() -> {
lock.lock();
try
{
System.out.println("b thread----外层调用lock");
}finally {
lock.unlock();
}
},"b").start();
}
}
五.死锁及排查
死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力干涉那它们都将无法推进下去,如果系统资源充足,进程的资源请求都能够得到满足,死锁出现的可能性就很低,否则就会因争夺有限的资源而陷入死锁。
产生死锁主要原因
系统资源不足、进程运行推进的顺序不合适、资源分配不当
死锁代码
import java.util.concurrent.TimeUnit;
public class DeadLockDemo{
public static void main(String[] args){
final Object objectLockA = new Object();
final Object objectLockB = new Object();
new Thread(() -> {
synchronized (objectLockA)
{
System.out.println(Thread.currentThread().getName()+"\t"+"自己持有A,希望获得B");
//暂停几秒钟线程
try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
synchronized (objectLockB)
{
System.out.println(Thread.currentThread().getName()+"\t"+"A-------已经获得B");
}
}
},"A").start();
new Thread(() -> {
synchronized (objectLockB)
{
System.out.println(Thread.currentThread().getName()+"\t"+"自己持有B,希望获得A");
//暂停几秒钟线程
try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
synchronized (objectLockA)
{
System.out.println(Thread.currentThread().getName()+"\t"+"B-------已经获得A");
}
}
},"B").start();
}
}
如何排查死锁
纯命令 jps -l jstack 进程编号
图形化 jconsole
六.Java内存模型Java Memory Model
JMM(Java 内存模型 Java Memory Model,简称 JMM) 本身是一种抽象的概念==并不真实存在==它仅仅描述的是一组约定或规范,通过这组规范定义了程序中(尤其是多线程)各个变量的读写访问方式,并决定一个线程对共享变量的写入何时以及如何变成对另一个线程可见,关键技术点都是围绕多线程的原子性、可见性和有序性展开的。
原则: JMM的关键技术点都是围绕多线程的==原子性、可见性和有序性==展开的
能干嘛? 1.通过JMM来实现线程和主内存之间的抽象关系。 2.屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
1.可见性
可见性是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道该变更 ,JMM规定了所有的变量都存储在==主内存==中。
Java中普通的共享变量不保证可见性,因为数据修改被写入内存的时机是不确定的,多线程并发下很可能出现"脏读",所以每个线程都有自己的==工作内存==,线程自己的工作内存中保存了该线程使用到的变量的==主内存副本拷贝==,线程对变量的所有操作(读取,赋值等 )都必需在线程自己的工作内存中进行,而不能够直接读写主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过==主内存==来完成。
线程脏读:如果没有可见性保证
主内存中有变量 x,初始值为 0 |
---|
线程 A 要将 x 加 1,先将 x=0 拷贝到自己的私有内存中,然后更新 x 的值 |
线程 A 将更新后的 x 值回刷到主内存的时间是不固定的 |
刚好在线程 A 没有回刷 x 到主内存时,线程 B 同样从主内存中读取 x,此时为 0,和线程 A 一样的操作,最后期盼的 x=2 就会变成 x=1 |
2.原子性
指一个操作是不可中断的,即多线程环境下,操作不能被其他线程干扰。
3.有序性
对于一个线程的执行代码而言,我们总是习惯性认为代码的执行总是从上到下,有序执行。但为了提供性能,编译器和处理器通常会对指令序列进行重新排序。 ==指令重排==可以保证串行语义一致,但没有义务保证多线程间的语义也一致,即可能产生"脏读",简单说,两行以上不相干的代码在执行的时候有可能先执行的不是第一条,不见得是从上到下顺序执行,执行顺序会被优化。
单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。处理器在进行重排序时必须要考虑指令之间的==数据依赖性==;多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。
4.JMM规范下,多线程对变量的读写过程
读取过程:
由于JVM运行程序的实体是线程,而==每个线程创建时JVM都会为其创建一个工作内存==(有些地方称为栈空间),工作内存是每个线程的私有数据区域;
==Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问==;
但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到的线程自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。
其简要访问过程如下图:
JMM定义了线程和主内存之间的抽象关系
1.线程之间的共享变量存储在主内存中(从硬件角度来说就是内存条)
2.每个线程都有一个私有的本地工作内存,本地工作内存中存储了该线程用来读/写共享变量的副本(从硬件角度来说就是CPU的缓存,比如寄存器、L1、L2、L3缓存等)
总结:
1.我们定义的所有共享变量都储存在物理主内存中
2.每个线程都有自己独立的工作内存,里面保存该线程使用到的变量的副本(主内存中该变量的一份拷贝)
3.线程对共享变量所有的操作都必须先在线程自己的工作内存中进行后写回主内存,不能直接从主内存中读写(不能越级)
4.不同线程之间也无法直接访问其他线程的工作内存中的变量,线程间变量值的传递需要通过主内存来进行(同级不能相互访问)
七.volatile与Java内存模型
1.volatile的内存语义
被volatile修改的变量有2大特点:可见性、有序性
volatile的内存语义
1.当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新回主内存中。
2.当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量
3.所以volatile的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取。
2.内存屏障(Memory Barriers)
2.1 概念
内存屏障(也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作),避免代码重排序。内存屏障其实就是一种JVM指令,Java内存模型的重排规则会==要求Java编译器在生成JVM指令时插入特定的内存屏障指令==,通过这些内存屏障指令,volatile实现了Java内存模型中的可见性和有序性,但volatile无法保证原子性。
==内存屏障之前==的所有==写==操作都要回写到主内存, ==内存屏障之后==的所有==读==操作都能获得内存屏障之前的所有写操作的最新结果(实现了可见性)。
因此重排序时,不允许把内存屏障之后的指令重排序到内存屏障之前。 一句话:对一个 volatile 域的写, happens-before 于任意后续对这个 volatile 域的读,也叫写后读。
2.2 四大屏障
Unsafe.java
2.3 happens-before 之 volatile 变量规则
2.4 JMM 就将内存屏障插⼊策略分为 4 种
写
- 在每个 volatile 写操作的前⾯插⼊⼀个 StoreStore 屏障
- 在每个 volatile 写操作的后⾯插⼊⼀个 StoreLoad 屏障
读
- 在每个 volatile 读操作的后⾯插⼊⼀个 LoadLoad 屏障
- 在每个 volatile 读操作的后⾯插⼊⼀个 LoadStore 屏障
3.volatile特性
3.1 保证可见性
保证不同线程对这个变量进行操作时的可见性,即变量一旦改变所有线程立即可见
import java.util.concurrent.TimeUnit;
public class VolatileSeeDemo{
static boolean flag = true; //不加volatile,没有可见性,程序无法停止
//static volatile boolean flag = true; //加了volatile,保证可见性,程序可以停止
public static void main(String[] args){
new Thread(() -> {
System.out.println(Thread.currentThread().getName()+"\t come in");
while (flag){
}
System.out.println(Thread.currentThread().getName()+"\t flag被修改为false,退出.....");
},"t1").start();
//暂停2秒钟后让main线程修改flag值
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = false;
System.out.println("main线程修改完成");
}
}
线程t1中为何看不到被主线程main修改为false的flag的值 ?
==问题可能:==
1.主线程修改了flag之后没有将其刷新到主内存,所以t1线程看不到。
2.主线程将flag刷新到了主内存,但是t1一直读取的是自己工作内存中flag的值,没有去主内存中更新获取flag最新的值。
==我们的诉求:== 1.线程中修改了工作内存中的副本之后,立即将其刷新到主内存; 2.工作内存中每次读取共享变量时,都去主内存中重新读取,然后拷贝到工作内存。
==解决:== 使用volatile修饰共享变量,就可以达到上面的效果,被volatile修改的变量有以下特点:
1.线程中读取的时候,每次读取都会去主内存中读取共享变量最新的值,然后将其复制到工作内存
2.线程中修改了工作内存中变量的副本,修改之后会立即刷新到主内存
3.2 没有原子性
volatile变量的复合操作(如i++)不具有原子性
import java.util.concurrent.TimeUnit;
class MyNumber{
volatile int number = 0;
public void addPlusPlus(){
number++;
}
}
public class VolatileNoAtomicDemo{
public static void main(String[] args) throws InterruptedException{
MyNumber myNumber = new MyNumber();
for (int i = 1; i <=10; i++) {
new Thread(() -> {
for (int j = 1; j <= 1000; j++) {
myNumber.addPlusPlus();
}
},String.valueOf(i)).start();
}
//暂停几秒钟线程
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "\t" + myNumber.number);
}
}
原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。 public void add(){ i++; //不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上1,分3步完成被拆分成3个指令:执行拿到原始值i;执行加1操作;执行写把累加后的值写回。 }
如果第二个线程在第一个线程读取旧值和写回新值期间读取 i 的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全失败,因此对于add方法必须使用synchronized修饰,以便保证线程安全。
多线程环境下,"数据计算"和"数据赋值"操作可能多次出现,即操作非原子。若数据在加载之后,若主内存count变量发生修改之后,由于线程工作内存中的值在此前已经加载,从而不会对变更操作做出相应变化,即私有内存和公共内存中变量不同步,进而导致数据不一致。 对于volatile变量,JVM只是保证从主内存加载到线程工作内存的值是最新的,也就是数据加载时是最新的。 ==由此可见volatile解决的是变量读时的可见性问题,但无法保证原子性,对于多线程修改共享变量的场景必须使用加锁同步==
3.3 指令禁重排
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段,有时候会改变程序语句的先后顺序。 不存在数据依赖关系,可以重排序;存在数据依赖关系,禁止重排序 但重排后的指令绝对不能改变原有的串行语义!这点在并发设计中必须要重点考虑!
重排序的分类和执行流程
1.编译器优化的重排序:
编译器在不改变单线程串行语义的前提下,可以重新调整指令的执行顺序
2.指令级并行的重排序:
处理器使用指令级并行技术来讲多条指令重叠执行,若不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
3.内存系统的重排序:
由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是乱序执行
4.数据依赖性:
若两个操作访问同一变量,且这两个操作中有一个为写操作,此时两操作间就存在数据依赖性。
不存在数据依赖关系,可以重排序===> 重排序OK 。
重排前 | 重排后 |
---|---|
int a = 1; //1 int b = 20; //2 int c = a + b; //3 | int b = 20; //1 int a = 1; //2 int c = a + b; //3 |
结论:编译器调整了语句的顺序,但是不影响程序的最终结果。 | 重排序OK |
存在数据依赖关系,禁止重排序===> 重排序发生,会导致程序运行结果不同。
编译器和处理器在重排序时,会遵守数据依赖性,不会改变存在依赖关系的两个操作的执行,但不同处理器和不同线程之间的数据性不会被编译器和处理器考虑,其只会作用于单处理器和单线程环境,下面三种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。
1)、volatile有关的禁止指令重排的行为
2)、四大屏障的插入情况
在每一个volatile写操作前面插入一个StoreStore屏障
StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作都已经刷新到主内存中。
在每一个volatile写操作后面插入一个StoreLoad屏障
StoreLoad屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序
在每一个volatile读操作后面插入一个LoadLoad屏障
LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。
在每一个volatile读操作后面插入一个LoadStore屏障
LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。
//模拟一个单线程,什么顺序读?什么顺序写?
public class VolatileTest {
int i = 0;
volatile boolean flag = false;
public void write(){
i = 2; //普通写
flag = true; //volatile写
}
public void read(){
if(flag){ //volatile读
System.out.println("---i = " + i); //普通读
}
}
}
4.总结面试考点
4.1 内存屏障是什么
内存屏障:是一种屏障指令,它使得CPU或编译器对屏障指令的前和后所发出的内存操作执行一个排序的约束。也叫内存栅栏或栅栏指令
4.2 内存屏障能干嘛
1.阻止屏障两边的指令重排序
2.写数据时加入屏障,强制将线程私有工作内存的数据刷回主物理内存
3.读数据时加入屏障,线程私有工作内存的数据失效,重新到主物理内存中获取最新数据
4.3 内存屏障四大指令
在每一个volatile写操作前面插入一个StoreStore屏障
在每一个volatile写操作后面插入一个StoreLoad屏障
在每一个volatile读操作后面插入一个LoadLoad屏障
在每一个volatile读操作后面插入一个LoadStore屏障
4.4 凭什么我们java写了一个volatile关键字,系统底层加入内存屏障?
字节码层面
关键字
他影响的是Class内的Field的flags:
添加了一个ACC_VOLATILE JVM在把字节码生成为机器码的时候,发现操作是volatile的变量的话,就会根据JMM的要求,在相应的位置去插入内存屏障指令。
4.5 volatile可见性
volatile关键字保证可见性,意味着:
1、对一个 volatie 修饰的变量进行读操作的话,总是能够读到这个变量的最新的值,也就是这个变量最后被修改的值
2、一个线程修改了 volatile 修饰的变量的值的时候,那么这个变量的新的值,会立即刷新回到主内存中
3、一个线程去读取 volatile 修饰的变量的值的时候,该变量在工作内存中的数据无效,需要重新到主内存去读取最新的数据
4.6 volatile禁重排
写指令
StoreStore 屏障
禁止上面的普通写和下面的 volatile 写操作重排序
前面所有的普通写的操作,数据都已经刷新到主内存普通写 和 volatile 写禁止重排;
volatile 写 和 volatile 写禁止重排
volatile写操作
StoreLoad 屏障
禁止上面的 volatile 写和下面的 volatile 读/写或普通写操作重排序前面
volatile 写的操作,数据都已经刷新到主内存volatile 写 和 普通写 禁止重排;
volatile 写和 volatile 读/写 禁止重排
读指令
volatile读操作
LoadLoad 屏障
禁止下面的普通读、volatile 读 和 上面的 volatie 读重排序
volatile 读 和普通读禁止重排;volatile 读 和 volatile 读禁止重排
LoadStore 屏障
禁止上面的 volatie 读 和 下面的 volatile 写或普通写 重排序
volatile 读 和 普通写 禁止重排;volatile 读和 volatie 写 禁止重排
volatile 写之前的操作,都禁止重排序到 volatile 之后
volatile 读之后的操作,都禁止重排序到 volatile 之前
volatile 写之后 volatile 读,禁止重排序的
八.线程安全的集合
1.CopyOnWrite机制
核心思想:读写分离,空间换时间,避免为保证并发安全导致的激烈的锁竞争。
划关键点:
1、CopyOnWrite适用于读多写少的情况,最大程度的提高读的效率;
2、CopyOnWrite是最终一致性,在写的过程中,原有的读的数据是不会发生更新的,只有新的读才能读到最新数据;
3、如何使其他线程能够及时读到新的数据,需要使用volatile变量;
4、写的时候不能并发写,需要对写操作进行加锁;
//写时复制
/*
* 添加元素api
*/
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
//复制一个array副本
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e; //往副本里写入
setArray(newElements); //副本替换原本,成为新的原本
return true;
} finally {
lock.unlock();
}
}
//读api
public E get(int index) {
return get(getArray(), index); //无锁
}
2.ConcurrentHashMap原理
ConcurrentHashMap的数据结构与HashMap基本类似,区别在于:
1、内部在数据写入时加了同步机制(分段锁)保证线程安全,读操作是无锁操作;
2、扩容时老数据的转移是并发执行的,这样扩容的效率更高。
2.1 Java7 ConcurrentHashMap基于ReentrantLock实现分段锁
2.2 Java8 ConcurrentHashMap基于分段锁+CAS保证线程安全,分段锁基于synchronized 关键字实现
重要成员变量
ConcurrentHashMap拥有出色的性能, 在真正掌握内部结构时, 先要掌握比较重要的成员:
-
LOAD_FACTOR: 负载因子, 默认75%, 当table使用率达到75%时, 为减少table的hash碰撞, tabel长度将扩容一倍。负载因子计算: 元素总个数%table.lengh
-
TREEIFY_THRESHOLD: 默认8, 当链表长度达到8时, 将结构转变为红黑树。
-
UNTREEIFY_THRESHOLD: 默认6, 红黑树转变为链表的阈值。
-
MIN_TRANSFER_STRIDE: 默认16, table扩容时, 每个线程最少迁移table的槽位个数。
-
MOVED: 值为-1, 当Node.hash为MOVED时, 代表着table正在扩容
-
TREEBIN, 置为-2, 代表此元素后接红黑树。
-
nextTable: table迁移过程临时变量, 在迁移过程中将元素全部迁移到nextTable上。
-
sizeCtl: 用来标志table初始化和扩容的,不同的取值代表着不同的含义:
- 0: table还没有被初始化
- -1: table正在初始化
- 小于-1: 实际值为resizeStamp(n)<<RESIZE_STAMP_SHIFT+2, 表明table正在扩容
- 大于0: 初始化完成后, 代表table最大存放元素的个数, 默认为0.75*n
-
transferIndex: table容量从n扩到2n时, 是从索引n->1的元素开始迁移, transferIndex代表当前已经迁移的元素下标
-
ForwardingNode: 一个特殊的Node节点, 其hashcode=MOVED, 代表着此时table正在做扩容操作。扩容期间, 若table某个元素为null, 那么该元素设置为ForwardingNode, 当下个线程向这个元素插入数据时, 检查hashcode=MOVED, 就会帮着扩容。
-
ConcurrentHashMap由三部分构成, table+链表+红黑树, 其中table是一个数组, 既然是数组, 必须要在使用时确定数组的大小, 当table存放的元素过多时, 就需要扩容, 以减少碰撞发生次数, 本文就讲解扩容的过程。扩容检查主要发生在插入元素(putVal())的过程:
-
一个线程插完元素后, 检查table使用率, 若超过阈值, 调用transfer进行扩容
-
一个线程插入数据时, 发现table对应元素的hash=MOVED, 那么调用helpTransfer()协助扩容。
1)、协助扩容helpTransfer
下面是协助扩容的过程
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) { //table扩容
Node<K,V>[] nextTab; int sc;
if (tab != null && (f instanceof ForwardingNode) && (nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
// 根据 length 得到一个标识符号
int rs = resizeStamp(tab.length);
while (nextTab == nextTable && table == tab && (sc = sizeCtl) < 0) {
//说明还在扩容
//判断是否标志发生了变化|| 扩容结束了
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || //达到最大的帮助线程 || 判断扩容转移下标是否在调整(扩容结束)
sc == rs + MAX_RESIZERS || transferIndex <= 0)
break;
// 将 sizeCtl + 1, (表示增加了一个线程帮助其扩容)
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) { transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
主要做了如下事情: 检查是否扩容完成 对sizeCtrl = sizeCtrl+1, 然后调用transfer()进行真正的扩容。
2)、扩容transfer
扩容的整体步骤就是新建一个nextTab, size是之前的2倍, 将table上的非空元素迁移到nextTab上面去。
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
// subdivide range,每个线程最少迁移16个槽位,大的话,最多
stride = MIN_TRANSFER_STRIDE;
// initiating 才开始初始化新的nextTab
if (nextTab == null) {
try {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
//扩容2倍
nextTab = nt;
} catch (Throwable ex) {
// try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
transferIndex = n;//更新的转移下标,
}
int nextn = nextTab.length;
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
//是否能够向前推进到下一个周期
boolean advance = true;
// to ensure sweep before committing nextTab,完成状态,如果是,则结束此方法
boolean finishing = false;
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
while (advance) {
//取下一个周期
int nextIndex, nextBound;
//本线程处理的区间范围为[bound, i),范围还没有处理完成,那么就继续处理
if (--i >= bound || finishing)
advance = false;
//目前处理到了这里(从大到小, 下线),开始找新的一轮的区间
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
//这个条件改变的是transferIndex的值,从16变成了1
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
//nextBound 是这次迁移任务的边界,注意,是从后往前
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound; //一块区间最小桶的下标
i = nextIndex - 1; //能够处理的最大桶的下标
advance = false;
}
}
if (i < 0 || i >= n || i + n >= nextn) {
//每个迁移线程都能达到这里
int sc;
if (finishing) { //迁移完成
nextTable = null;
//直接把以前的table丢弃了,上面的MOVE等标志全部丢弃,使用新的
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1); //扩大2n-0.5n = 1.50n, 更新新的容量阈值
return;
}
//表示当前线程迁移完成了
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
//注意此时sc的值并不等于sizeCtl,上一步,sizeCtl=sizeCtl-1了。这两个对象还是分割的
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n; // recheck before commit
}
}
//如果对应位置为null, 则将ForwardingNode放在对应的地方
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED)
//别的线程已经在处理了,再推进一个下标
advance = true;
// already processed,推动到下一个周期,仍然会检查i与bound是否结束
else { //说明位置上有值了,
//需要加锁,防止再向里面放值,在放数据时,也会锁住。比如整个table正在迁移,还没有迁移到这个元素,另外一个线程向这个节点插入数据,此时迁移到这里了,会被阻塞住
synchronized (f) {
if (tabAt(tab, i) == f) {
//判断i下标和f是否相同
Node<K,V> ln, hn; //高位桶, 地位桶
if (fh >= 0) {
int runBit = fh & n; //n为2^n, 取余后只能是2^n
Node<K,V> lastRun = f;
//找到最后一个不和fn相同的节点
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
//只要找到这,之后的取值都是一样的,下次循环时,就不用再循环后面的
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
} else {
//比如1,16,32,如果低位%16,那么肯定是0。
hn = lastRun;
ln = null;
}
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
//这样就把相同串的给串起来了
ln = new Node<K,V>(ph, pk, pv, ln);
else
//这样就把相同串的给串起来了,注意这里ln用法,第一个next为null,烦着串起来了。
hn = new Node<K,V>(ph, pk, pv, hn);
}
setTabAt(nextTab, i, ln); //反着给串起来了
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
else if (f instanceof TreeBin) { // 如果是红黑树
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> lo = null, loTail = null; //也是高低节点
TreeNode<K,V> hi = null, hiTail = null;//也是高低节点
int lc = 0, hc = 0;
for (Node<K,V> e = t.first; e != null; e = e.next) {
//中序遍历红黑树
int h = e.hash;
TreeNode<K,V> p = new TreeNode<K,V>
(h, e.key, e.val, null, null);
if ((h & n) == 0) { //0的放低位
//注意这里p.prev = loTail,每一个p都是下一个的prev
if ((p.prev = loTail) == null)
lo = p; //把头记住
else
loTail.next = p; //上一次的p的next是这次的p
loTail = p; //把上次p给记住
++lc;
}
else { //高位
if ((p.prev = hiTail) == null)
hi = p; //把尾记住
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
//判断是否需要转化为树
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
//如果没有高低的话,则部分为两个树
(hc != 0) ? new TreeBin<K,V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin<K,V>(hi) : t;
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
其中有两个变量需要了解下:
- advance: 表示是否可以向下一个轮元素进行迁移。
- finishing: table所有元素是否迁移完成。
大致做了如下事情:
- 确定线程每轮迁移元素的个数stride, 比如进来一个线程, 确定扩容table下标为(a,b]之间元素, 下一个线程扩容(b,c]。这里对b-a或者c-b也是由最小值16限制的。 也就是说每个线程最少扩容连续16个table的元素。而标志当前迁移的下标保存在transferIndex里面。
- 检查nextTab是否完成初始化, 若没有的话, 说明是第一个迁移的线程, 先初始化nextTab, size是之前table的2倍。
- 进入while循环查找本轮迁移的table下标元素区间, 保存在(bound, i]中, 注意这里是半开半闭区间。
- 从i -> bound开始遍历table中每个元素, 这里是从大到小遍历的:
- 若该元素为空, 则向该元素标写入ForwardingNode, 然后检查下一个元素。 当别的线程向这个元素插入数据时, 根据这个标志符知道了table正在被别的线程迁移, 在putVal中就会调用helpTransfer帮着迁移。
- 若该元素的hash=MOVED, 代表次table正在处于迁移之中, 跳过。 按道理不会跑着这里的。
- 否则说明该元素跟着的是一个链表或者是个红黑树结构, 若hash>0, 则说明是个链表, 若f instanceof TreeBin, 则说明是个红黑树结构。
- 链表迁移原理如下: ==遍历链表每个节点。 若节点的f.hash&n==0成立, 则将节点放在i, 否则, 则将节点放在n+i上面。==
迁移前, 对该元素进行加锁。 遍历链表时, 这里使用lastRun变量, 保留的是上次hash的值, 假如整个链表全部节点f.hash&n==0, 那么第二次遍历, 只要找到lastRun的值, 那么认为之后的节点都是相同值, 减少了不必要的f.hash&n取值。遍历完所有的节点后, 此时形成了两条链表, ln存放的是f.hash&n=0的节点, hn存放的是非0的节点, 然后将ln存放在nextTable第i元素的位置, n+i存放在n+i的位置。
蓝色节点代表:f.hash&n==0, 绿色节点代表f.hash&n!=0。 最终蓝色的节点仍在存放在(0, n)范围里, 绿的节点存放在(n, 2n-1)的范围之内。
- 迁移链表和红黑树的原理是一样的, 在红黑树中, 我们记录了每个红黑树的first(这个节点不是hash最小的节点)和每个节点的next, 根据这两个元素, 我们可以访问红黑树所有的元素, 红黑树此时也是一个链表, 红黑树和链表迁移的过程一样。红黑树根据迁移后拆分成了hn和ln, 根据链表长度确定链表是红黑树结构还是退化为了链表。
- 如何确定table所有元素迁移完成:
//表示当前线程迁移完成了
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
//注意此时sc的值并不等于sizeCtl,上一步,sizeCtl=sizeCtl-1了。这两个对象还是分割的
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n; // recheck before commit
}
第一个线程开始迁移时, 设置了sizeCtl= resizeStamp(n) << RESIZE_STAMP_SHIFT+2, 此后每个新来帮助迁移的线程都会sizeCtl=sizeCtl+1, 完成迁移后,sizeCtl-1, 那么只要有一个线程还处于迁移状态, 那么sizeCtl> resizeStamp(n) << RESIZE_STAMP_SHIFT+2一直成立, 当只有最后一个线程完成迁移之后, 等式两边才成立。 可能大家会有疑问, 第一个线程并没有对sizeCtl=sizeCtl+1, 此时完成后再减一, 那不是不相等了吗, 注意这里, sizeCtl在减一前, 将值赋给了sc, 等式比较的是sc。
3)、总结
table扩容过程就是将table元素迁移到新的table上, 在元素迁移时, 可以并发完成, 加快了迁移速度, 同时不至于阻塞线程。所有元素迁移完成后, 旧的table直接丢失, 直接使用新的table。
九.Java对象内存布局和对象头
1.对象的内存布局
根据周志明老师JVM第3版书中
在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:
对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
2.对象在堆内存中的存储布局
对象内部结构分为:对象头、实例数据、对齐填充(保证8个字节的倍数)。
2.1 对象头
1)、对象标记Mark Word
默认存储对象的HashCode、分代年龄和锁标志位等信息。
这些信息都是与对象自身定义无关的数据,所以MarkWord被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。 它会根据对象的状态复用自己的存储空间,也就是说==在运行期间MarkWord里存储的数据会随着锁标志位的变化而变化==。
2)、类型指针(又叫类元信息)
对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
3)、对象头多大?
在64位系统中,Mark Word占了8个字节,类型指针占了8个字节,一共是16个字节。
2.2 实例数据
存放类的属性(Field)数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
2.3 对齐填充
虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这部分内存按8字节补充对齐。
2.4 总结
Hotspot术语表官网
http://openjdk.java.net/groups/hotspot/docs/HotSpotGlossary.html
3.64位虚拟机
hash: 保存对象的哈希码 age: 保存对象的分代年龄 biased_lock: 偏向锁标识位 lock: 锁状态标识位 JavaThread* :保存持有偏向锁的线程ID epoch: 保存偏向时间戳
markword(64位)分布图 对象布局、GC回收和后面的锁升级就是对象标记MarkWord里面标志位的变化
4.JOL工具使用
4.1 JOL官网
JOL Java Object Layout 分析对象在JVM的大小和分布
官网:OpenJDK: jol
<!--
官网:http://openjdk.java.net/projects/code-tools/jol/
定位:分析对象在JVM的大小和分布
-->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
public class Test {
public static void main(String[] args) {
//VM的信息
System.out.println(VM.current().details());
//所有对象分配的字节都是8的整数倍
System.out.println(VM.current().objectAlignment());
}
}
4.2 分析Object obj = new Object();
public class Test {
public static void main(String[] args) {
Object obj = new Object();
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
/*
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x200001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
*/
}
}
//Mark Word8字节 类型指针4字节(指针类型压缩) 对齐4字节 = 8+4+4 = 16字节
OFFSET | 偏移量,也就是到这个字段位置所占用的byte字节数 |
---|---|
SIZE | 后面类型的字节大小 |
TYPE | 是Class中定义的类型 |
DESCRIPTION | DESCRIPTION是类型的描述 |
VALUE | VALUE是TYPE在内存中的值 |
4.3 分析User user = new User();
public class MainClass {
public static void main(String[] args) {
User user = new User();
System.out.println(ClassLayout.parseInstance(user).toPrintable());
}
}
class User {
int age = 18;
char sex = '男';
long d;
}
4.4 关于运行参数
1)、GC年龄运行参数
GC年龄采用4位bit存储,最大为15( 4bit分代年龄,1111是最大值即15 ),例如MaxTenuringThreshold参数默认值就是15。
-XX:MaxTenuringThreshold=16
2)、查看运行时默认携带参数
命令: cmd + java -XX:+PrintCommandLineFlags -version
-XX:-UseCompressedClassPointers
5.Synchronized的性能变化
Java5之前,只有Synchronized,这个是操作系统级别的重量级操作。用户态和内核态(操作系统级别)之间的切换
Java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。
在Java早期版本中,==synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的==,挂起线程和恢复线程都需要转入内核态去完成,阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间,如果同步代码块中内容过于简单,这种切换的时间可能比用户代码执行的时间还长”,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。
Monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的转换,成本非常高。
Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁。
synchronized锁:由对象头中的Mark Word根据锁标志位的不同而被复用及锁升级策略
无锁:程序不会有锁的竞争
偏向锁:当一段同步代码一直被同一个线程多次访问,由于只有一个线程那么该线程在后续访问时便会自动获得锁
轻量级锁:有线程来参与锁的竞争,但是获取锁的冲突时间极短
重量级锁:有大量的线程参与锁的竞争,冲突性很高
各种锁优缺点、synchronized锁升级和实现原理:
synchronized锁升级过程总结:一句话,就是先自旋,不行再阻塞。实际上是把之前的悲观锁(重量级锁)变成在一定条件下使用偏向锁以及使用轻量级(自旋锁CAS)的形式。
synchronized在修饰方法和代码块在字节码上实现方式有很大差异,但是内部实现还是基于对象头的MarkWord来实现的。
JDK1.6之前synchronized使用的是重量级锁,==JDK1.6之后进行了优化,拥有了无锁->偏向锁->轻量级锁->重量级锁的升级过程==,而不是无论什么情况都使用重量级锁。
偏向锁:适用于单线程适用的情况,在不存在锁竞争的时候进入同步方法/代码块则使用偏向锁。在JDK 15中,默认情况下禁用偏向锁,并弃用所有相关的命令行选项。
轻量级锁:适用于竞争较不激烈的情况(这和乐观锁的使用范围类似), 存在竞争时升级为轻量级锁,轻量级锁采用的是自旋锁,如果同步方法/代码块执行时间很短的话,采用轻量级锁虽然会占用cpu资源但是相对比使用重量级锁还是更高效。
重量级锁:适用于竞争激烈的情况,如果同步方法/代码块执行时间很长,那么使用轻量级锁自旋带来的性能消耗就比使用重量级锁更严重,这时候就需要升级为重量级锁。
十.ThreadLocal
1.ThreadLocal简介
是什么?
ThreadLocal提供线程局部变量。这些变量与正常的变量不同,因为每一个线程在访问ThreadLocal实例的时候(通过其get或set方法)==都有自己的、独立初始化的变量副本==。ThreadLocal实例通常是类中的私有静态字段,使用它的目的是希望将状态(例如,用户ID或事务ID)与线程关联起来。
能干嘛?
实现==每一个线程都有自己专属的本地变量副本==(自己用自己的变量不麻烦别人,不和其他人共享,人人有份,人各一份),主要解决了让每个线程绑定自己的值,通过使用get()和set()方法,获取默认值或将其值更改为当前线程所存的副本的值从而避免了线程安全问题。
API介绍
//T get ()
Returns the value in the current thread's copy of this thread-local variable.
//protected T initialValue ()
Returns the current thread's "initial value" for this thread-local variable.
//void remove ( )
Removes the current thread's value for this thread-local variable.
//void set(T value)
Sets the current thread's copy of this thread-local variable to the specified value.
//static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier)
Creates a thread local variable.
2.ThreadLocal案例
synchronized 三个售票员卖完50张票务,总量完成即可
class TicketDemo{
int num = 50;
public synchronized void saleTicket(){
if(num>0){
System.out.println(Thread.currentThread().getName()+"号售票员卖出第:"+(num--));
}else{
System.out.println("=========卖完了=========");
}
}
}
public class ThreadLocalDemo1 {
public static void main(String[] args) {
TicketDemo ticket = new TicketDemo();
for (int i = 1; i <=3 ; i++) {
new Thread(()->{
for (int j = 0; j <20; j++) {
ticket.saleTicket();
try {
TimeUnit.MILLISECONDS.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
} , String.valueOf(i)).start();
}
}
}
ThreadLocal 卖房子不参加总和计算,希望销售各自为政,各凭销售本事提成,按照出单数各自统计
class House{
//初始值为 0
ThreadLocal<Integer> threadLocal = ThreadLocal.withInitial(() -> 0);
public void saleHouse(){
Integer value = threadLocal.get();
value++;
threadLocal.set(value);
}
}
public class ThreadLocalDemo2 {
public static void main(String[] args) {
House house = new House();
new Thread(() -> {
try {
for (int i = 1; i <=3; i++) {
house.saleHouse();
}
System.out.println(Thread.currentThread().getName()+"\t"+"---"+house.threadLocal.get());
}finally {
house.threadLocal.remove();//如果不清理自定义的 ThreadLocal 变量,可能会影响后续业务逻辑和造成内存泄露等问题
}
},"1号销售").start();
new Thread(() -> {
try {
for (int i = 1; i <=2; i++) {
house.saleHouse();
}
System.out.println(Thread.currentThread().getName()+"\t"+"---"+house.threadLocal.get());
}finally {
house.threadLocal.remove();
}
},"2号销售").start();
new Thread(() -> {
try {
for (int i = 1; i <=5; i++) {
house.saleHouse();
}
System.out.println(Thread.currentThread().getName()+"\t"+"---"+house.threadLocal.get());
}finally {
house.threadLocal.remove();
}
},"3号销售").start();
//主线程 未参与售房
System.out.println(Thread.currentThread().getName()+"\t"+"---"+house.threadLocal.get());
}
}
总结:
1.因为每个 Thread 内有自己的==实例副本==且该副本只由当前线程自己使用。
2.既然其它 Thread 不可访问,那就不存在多线程间共享的问题。
3.统一设置初始值,但是每个线程对这个值的修改都是各自线程互相独立的。
如何才能不争抢?
1.加入synchronized或者Lock控制资源的访问顺序
2.人手一份,大家各自安好,没必要抢夺
3.ThreadLocal最佳实践
讨论非线程安全的SimpleDateFormat
官方的一段介绍
* Date formats are not synchronized.
* It is recommended to create separate format instances for each thread.
* If multiple threads access a format concurrently, it must be synchronized
* externally.
翻译一下
//SimpleDateFormat中的日期格式不是同步的。
//推荐(建议)为每个线程创建独立的格式实例。如果多个线程同时访问一个格式,则它必须保持外部同步。
SimpleDateFormat类内部有一个Calendar对象引用,它用来储存和这个SimpleDateFormat相关的日期信息,例如sdf.parse(dateStr),sdf.format(date) 诸如此类的方法参数传入的日期相关String,Date等等, 都是交由Calendar引用来储存的.这样就会导致一个问题如果你的SimpleDateFormat是个static的, 那么多个thread 之间就会共享这个SimpleDateFormat, 同时也是共享这个Calendar引用
public class DateUtils {
public static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
/**
* 模拟并发环境下使用SimpleDateFormat的parse方法将字符串转换成Date对象
* @param string
* @return
* @throws Exception
*/
public static Date parseDate(String string)throws Exception {
return sdf.parse(string);
}
public static void main(String[] args) throws Exception {
for (int i = 1; i <=5; i++) {
new Thread(() -> {
try {
System.out.println(DateUtils.parseDate("2022-11-11 11:11:11"));
} catch (Exception e) {
e.printStackTrace();
}
},String.valueOf(i)).start();
}
}
}
解决方案一
将SimpleDateFormat定义成局部变量。
public class DateUtils {
public static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
/**
* 模拟并发环境下使用SimpleDateFormat的parse方法将字符串转换成Date对象
* @param string
* @return
* @throws Exception
*/
public static Date parseDate(String string)throws Exception {
return sdf.parse(string);
}
public static void main(String[] args) throws Exception {
for (int i = 1; i <=5; i++) {
new Thread(() -> {
try {
//方式一:将SimpleDateFormat定义成局部变量。
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
System.out.println(sdf.parse("2020-11-11 11:11:11"));
sdf = null;
} catch (Exception e) {
e.printStackTrace();
}
},String.valueOf(i)).start();
}
}
}
缺点:每调用一次方法就会创建一个SimpleDateFormat对象,方法结束又要作为垃圾回收。
解决方案二
采用静态同步方法
public class SynDateUtils {
public static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
/**
* 方式二:采用同步方法synchronized
* 模拟并发环境下使用SimpleDateFormat的parse方法将字符串转换成Date对象
* @param string
* @return
* @throws Exception
*/
public static synchronized Date parseDate(String string)throws Exception {
return sdf.parse(string);
}
public static void main(String[] args) throws Exception {
for (int i = 1; i <=5; i++) {
new Thread(() -> {
try {
System.out.println(SynDateUtils.parseDate("2022-11-11 11:11:11"));
} catch (Exception e) {
e.printStackTrace();
}
},String.valueOf(i)).start();
}
}
}
解决方案三
ThreadLocal,也叫做线程本地变量或者线程本地存储。
public class ThreadLocalDateUtils {
private static final ThreadLocal<SimpleDateFormat> sdf_threadLocal =
ThreadLocal.withInitial(()-> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
/**
* ThreadLocal可以确保每个线程都可以得到各自单独的一个SimpleDateFormat的对象,那么自然也就不存在竞争问题了。
* @param string
* @return
* @throws Exception
*/
public static Date parseDateTL(String string)throws Exception{
return sdf_threadLocal.get().parse(string);
}
public static void main(String[] args) throws Exception{
for (int i = 1; i <=5; i++) {
new Thread(() -> {
try {
System.out.println(ThreadLocalDateUtils.parseDateTL("2020-11-11 11:11:11"));
} catch (Exception e) {
e.printStackTrace();
} finally {
sdf_threadLocal.remove();
}
},String.valueOf(i)).start();
}
}
}
ThreadLocal可以确保每个线程都可以得到各自单独的一个SimpleDateFormat的对象,那么自然也就不存在竞争问题了。
解决方案四
使用线程安全的时间类DateTimeFormatter。
JDK8中,可以使用LocalDateTime代替 Calendar , DateTimeFormatter代替 SimpleDateFormat
官方给出的解释: simple beautiful strong immutable thread-safe。
public class DateTimeFormatterUtils {
public static final DateTimeFormatter sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public static String format(LocalDateTime localDateTime){
return sdf.format(localDateTime);
}
public static LocalDateTime parse(String string){
return LocalDateTime.parse(string , sdf);
}
public static void main(String[] args) throws Exception {
for (int i = 1; i <=5; i++) {
new Thread(() -> {
try {
System.out.println(DateTimeFormatterUtils.parse("2022-11-11 11:11:11"));
} catch (Exception e) {
e.printStackTrace();
}
},String.valueOf(i)).start();
}
}
}
4.Thread,ThreadLocal,ThreadLocalMap 关系
Thread类
public class Thread implements Runnable {
/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;
}
ThreadLocal类
public class ThreadLocal<T> {
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
}
}
ThreadLocalMap实际上就是一个以ThreadLocal实例为key,任意对象为value的Entry对象。 当我们为ThreadLocal变量赋值,实际上就是以当前ThreadLocal实例为key,值为value的Entry往这个ThreadLocalMap中存放。
==JVM内部维护了一个线程版的Map<Thread,T>==(通过ThreadLocal对象的set方法,结果把ThreadLocal对象自己当做key,放进了ThreadLocalMap中),每个线程要用到这个T的时候,用当前的线程去Map里面获取,通过这样让==每个线程都拥有了自己独立的变量==,人手一份,竞争条件被彻底消除,==在并发模式下是绝对安全的变量==。
5.ThreadLocal内存泄露问题
5.1 什么是内存泄漏
不再会被使用的对象或者变量占用的内存不能被回收,就是内存泄露。
5.2 强引用、软引用、弱引用、虚引用的区别
==1、强引用(StrongReference)== 最普遍的一种引用方式,如String s = new String (“abc”),变量s就是字符串“abc”的强引用,只要还有强引用指向一个对象,就能表明对象还活着,垃圾回收不回收这种对象。如果要对强引用进行垃圾回收,需要设置强引用对象为 null ,即 s = null , 或者让其超出对象的生命周期范围,则认为该对象不存在引用。
==2、软引用(SoftReference)== 用于描述还有用但非必须的对象,如果内存足够,不回收,如果内存不足,则回收。一般用于实现内存敏感的高速缓存,软引用可以和引用队列ReferenceQueue联合使用,如果软引用的对象被垃圾回收,JVM就会把这个软引用加入到与之关联的引用队列中。
==3、弱引用(WeakReference)== 弱引用和软引用大致相同,弱引用与软引用的区别在于 :弱引用拥有更短暂的生命周期,不管内存够不够,都会回收,都会回收它的内存。
在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。短时间内通过弱引用取对应的数据,可以取到,当执行过第二次垃圾回收时,将返回null。弱引用主要用于监控对象是否已经被垃圾回收器标记为即将回收的垃圾,可以通过弱引用的isEnQueued方法返回对象是否被垃圾回收器标记。
==4、虚引用(PhantomReference)== 就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收器回收的活动。 虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。
5.3 Thread,ThreadLocal,ThreadLocalMap 关系
1.每个Thread对象维护着一个ThreadLocalMap的引用
2.ThreadLocalMap是ThreadLocal的内部类,用Entry来进行存储
3.调用ThreadLocal的set()方法时,实际上就是往ThreadLocalMap设置值,key是ThreadLocal对象,值Value是传递进来的对象
4.调用ThreadLocal的get()方法时,实际上就是往ThreadLocalMap获取值,key是ThreadLocal对象
5.ThreadLocal本身并不存储值,它只是自己作为一个key来让线程从ThreadLocalMap获取value,正因为这个原理,所以ThreadLocal能够实现“数据隔离”,获取当前线程的局部变量值,不受其他线程影响
5.4 Entry为什么要用弱引用
当function01方法执行完毕后,栈帧销毁强引用 tl 也就没有了。但此时线程的ThreadLocalMap里某个Entry的key引用还指向这个对象。
若这个Key引用是强引用,就会导致key指向的ThreadLocal对象及V指向的对象不能被GC回收,造成内存泄漏;
若这个key引用是弱引用,就大概率会减少内存泄漏的问题(还有一个key为null的雷)。使用弱引用,就可以使ThreadLocal对象在方法执行完毕后顺利被回收且Entry的key引用指向为null。
1.当我们为ThreadLocal变量赋值,实际上就是当前的Entry (ThreadLocal实例为key,值为value)往这个ThreadLocalMap中存放。
Entry中的key是弱引用,当ThreadLocal外部强引用被置为null (tl=null) , 那么系统 GC 的时候,根据可达性分析,这个ThreadLocal实例就没有任何一条链路能够引用到它,这个ThreadLocal势必会被回收,这样一来,ThreadLocalMap中就会出现Key为null的Entry,就没有办法访问这些Key为null的Entry的value,如果当前线程再迟迟不结束的话,这些Key为null的Entry的Value就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value永远无法回收,造成内存泄漏。
2.当然,如果当前Thread运行结束,ThreadLocal,ThreadLocalMap, Entry没有引用链可达,在垃圾回收的时候都会被系统进行回收。
3.但在实际使用中我们有时候会用线程池去维护我们的线程,比如在Executors.newFixedThreadPool()时创建线程的时候,为了复用线程是不会结束的,所以ThreadLocal内存泄漏就值得我们小心。
5.5 解决ThreadLocal内存泄露问题
ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal没有外部强引用引用他,那么系统GC的时候,这个ThreadLocal势必会被回收,这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value,如果当前线程再迟迟不结束的话(比如正好用在线程池),这些key为null的Entry的value就会一直存在一条强引用链。Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value永远无法回收,造成内存泄漏。
虽然弱引用,保证了key指向的ThreadLocal对象能被及时回收,但是v指向的value对象是需要ThreadLocalMap调用get、set时发现key为null时才会去回收整个entry、value,因此弱引用不能100%保证内存不泄露。
==我们要在不使用某个ThreadLocal对象后,手动调用remove()方法来删除它==,尤其是在线程池中,不仅仅是内存泄露的问题,因为线程池中的线程是重复使用的,意味着这个线程的ThreadLocalMap对象也是重复使用的,如果我们不手动调用remove方法,那么后面的线程就有可能获取到上个线程遗留下来的value值,造成bug。
从set,getEntry,remove方法看出,在ThreadLocal的生命周期里,针对ThreadLocal存在的内存泄漏的问题,都会通过expungeStaleEntry,cleanSomeSlots, replaceStaleEntry这三个方法清理掉key为null的脏entry。
6.总结
1.ThreadLocal 并不解决线程间共享数据的问题
2.ThreadLocal 适用于变量在线程间隔离且在方法间共享的场景
3.ThreadLocal 通过隐式的在不同线程内创建独立实例副本避免了实例线程安全的问题
4.每个线程持有一个只属于自己的专属Map并维护了ThreadLocal对象与具体实例的映射,该Map由于只被持有它的线程访问,故不存在线程安全以及锁的问题
5.ThreadLocalMap的Entry对ThreadLocal的引用为弱引用,避免了ThreadLocal对象无法被回收的问题
6.都会通过expungeStaleEntry,cleanSomeSlots, replaceStaleEntry这三个方法回收键为 null 的 Entry 对象的值(即为具体实例)以及 Entry 对象本身从而防止内存泄漏,属于安全加固的方法
标签:JUC,进阶,ThreadLocal,volatile,内存,线程,JavaSE,public,变量 From: https://blog.csdn.net/qq_52668773/article/details/137332370