线程学习总结

文章详细介绍了Java中的线程相关知识,包括线程的创建方式、线程上下文切换、线程状态、锁的原理和类型,如synchronized、轻量级锁、偏向锁、自旋锁等。此外,还探讨了Java内存模型(JMM)中的可见性和有序性问题,以及volatile关键字的作用。文章还提到了无锁并发编程,如CAS操作和Atomic类的使用,并分析了线程池的实现和使用场景。
摘要由CSDN通过智能技术生成

一、线程

创建线程的方法

方法一

继承

继承Thread类,重写run()方法,调start方法运行。

方法二

组合

Thread类和Runnable接口组合的形式

调用Thread的有参构造方法,使用匿名内部类,传递runnable对象重写run()方法。调start方法运行。

方法三

FutureTask配合Thread

FutureTask能够接收Callable类型的参数,用来处理有返回结果的情况。

// 创建任务对象
FutureTask<Integer> task = new FutureTask<>(() -> {
	log.debug("hello");
	return 100;
})

// 参数1是任务对象,参数2是线程名字
new Thread(task,"t1").start();

// 主线程阻塞,同步等待task执行完毕的结果
Integer result = task.get();
log.debug(result);
栈与栈帧

Java Virtual Machine Stacks(Java虚拟机栈)

  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
  • 线程的栈内存是相互独立的,每个线程有自己的独立的栈内存,里面有多个栈帧。
线程上下文切换(Thread Context Switch)

任务调度器把时间片分配给每个线程运行的时候,每个线程的时间片终会用完,时间片用完后,CPU的使用权就要交给其他线程,此时,当前线程就会发生一次上下文切换。

从使用CPU到不使用CPU,为一次上下文切换。

因为以下一些原因导致CPU不在执行当前线程,转而执行另一个线程的代码

  • 线程的cpu时间片用完
  • 垃圾回收
  • 有更高优先级的线程需要运行
  • 线程自己调用了sleep、yield、wait、join、park、synchronized、lock等方法

当Context Swich发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java中对应的概念就是程序计数器(Program Counter Register),它的作用是记住下一条jvm指令的执行地址,是线程私有的。

  • 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等。
  • Context Switch频繁发生会影响性能。

3、常见方法

方法名功能说明注意
start()启动一个新线程,在新的线程运行run方法中的代码start方法只是让线程进入就绪状态,里面代码不一定立刻执行(CPU的时间片还没分给它)。每个线程对象的start方法只能调用一次,如果调用多次会出现IllegalThreadStateException
run()新线程启动后会调用的方法如果在构造Thread对象时传递了Runnable参数,则线程启动后会调用Runnable中的run方法,否则默认不执行任何操作。但可以创建Thread的子类对象,来覆盖默认行为。
join()等待线程运行结束A线程需要等待B线程的结果,则在A线程的代码中,B线程调用join方法。
join(long n)等待线程运行结束,最多等待n毫秒
getId()获取线程长整型的idid唯一
getName()获取线程名
setName()修改线程名
getPriority()获取线程优先级
setPriority(int)修改线程优先级Java中规定线程优先级是1~10的整数,较大的优先级能提高该线程被CPU调度的几率;CPU比较闲的时候,优先级忽略不计,当CPU比较忙的时候,优先级越高,被调度的几率越大。
getState()获取线程状态Java中线程状态是用6个enum表示,分别为:NEW,RUNNABLE,BLOCKED,WAITING,TIMED_WAITING,TERMINATED TIMED_WAITING是具有指定等待时间的等待线程的线程状态
isInterrupted()判断是否被打断不会清除打断标记
isAlive()线程是否存活(还没有运行完毕)
interrupt()打断线程如果被打断线程正在sleep、wait、join会导致被打断的线程抛出InterruptedException,并清除打断标记;如果打断的正在运行的线程,则会设置打断标记;park的线程被打断,也会设置打断标记
interrupted() static判断当前线程是否被打断会清除打断标记
currentThread() static获取当前正在执行的线程
sleep(long n) static让当前执行的线程休眠n毫秒,休眠时让出cpu的时间片给其它线程
yield() static提示线程调度器让出当前线程对CPU的使用主要是为了测试和调试

4、sleep与yield

sleep
  • 调用sleep会让当前线程从Running进入Timed Waiting状态(阻塞)
  • 其他线程可以使用interrupt() 方法打断正在睡眠的线程,这时sleep方法会抛出InterruptedException
  • 睡眠结束后的线程未必会立刻得到执行
  • 建议用TimeUnit的sleep方法代替Thread的sleep来获得更好的可读性。
yield
  • 调用yield会让当前线程从Running进入Runnable状态,然后调度执行其它同优先级的线程。如果这时没有同优先级的线程,那么不能保证让当前线程暂停的效果
  • 具体的实现依赖于操作系统的任务调度器
线程优先级
  • 线程优先级会提示调度器优先调度该线程,但它仅仅是一个提示,调度器可以忽略它
  • 如果cpu比较忙,那么优先级高的线程会获得更多的时间片,但cpu闲时,优先级几乎没有作用
join

如果主线程需要使用或者等待t1线程执行结束后在执行,则在主线程执行的代码中,t1线程调用join方法。

应用之同步

以调用方角度来讲,如果

  • 需要等待结果返回,才能继续运行就是同步
  • 不需要等待结果返回,就能继续运行就是异步
interrupt()

如果打断的是正在运行的线程,是将isInterrupted()的值置为true做个标记,但是线程并没有停止,后面可以判断是否被打断,进行一个停止运行前的处理,避免线程被强制终止。 被打断的线程是由自己决定是继续运行还是停止运行。

if (interrupted) { break ;}

5、Java对象头

Java对象由两部分组成,对象头和对象中的成员变量;

以32位jvm为例

普通对象:

对象头占64位(8个字节),前面4个字节是Mark Word,后面4个字节是Klass Word;

数组对象:

对象头占96位(12个字节),前面4个字节是Mark Word,后面4个字节是Klass Word;最后是4个字节的记录数组长度;

Mark Word结构
  • hashcode:每个对象的哈希码(25位)
  • age:垃圾回收的分代年龄(4位)
  • biased_lock:0 :是否是偏向锁,0单表否,1代表是。
  • 01:表示加锁状态;01表示没有加锁;00表示轻量级锁;10表示重量级锁。

当对象加锁后,,加锁状态改变;同时前面的30位记录的不再是hashcode、age等,而是存放指向Monitor的指针地址、或者存放指向锁记录(Lock Record)的地址。

由于synchronized是重量级锁,所以引入以下机制进行优化synchronized。

6、Monitor(锁)

每个Java对象都可以关联一个(操作系统提供的,Java中看不到)Monitor对象,那么什么时候关联呢,就是在使用synchronized关键字给对象上锁的时候,该对象的对象头的Mark Word中就被设置指向Monitor对象的指针。

  • Owner
    • 当第一个线程尝试获取锁,先通过对象的对象头找到Monitor,然后再查看Monitor的Woner是否为null,如果为null,则表示可以获取锁,则该线程为Owner。
    • 当后面再有线程尝试获取锁,发现Owner已经有值,则会等待,加入EntryList。
  • EntryList
    • 当Owner中的线程执行完同步代码块的内容,唤醒EntryList中等待的线程来竞争锁,非公平竞争。
  • WaitSet

注意:

  • synchronized必须是进入同一个对象的Monitor才有上述的效果
  • 不加synchronized的对象不会关联监视器,不遵从以上规则。

7、轻量级锁

使用场景:如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(没有竞争),那么可以使用轻量级锁来优化。

如果有竞争了,轻量级锁会升级为重量级锁。

轻量级锁对使用者是透明的,语法依然是synchronized。

调用synchronized时候,默认优先使用轻量级锁,如果轻量级锁加锁失败,则升级为重量级锁。

轻量级锁步骤

  • 创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word。
  • 起初,锁记录分为两个部分,一个用于存储锁记录地址,一个用于存储对象引用。
  • 然后锁记录中Object reference指向锁对象,并尝试用cas替换Object的Mark Word,将Mark Word的值植入锁记录。
  • 如果cas替换成功,对象头中存储了锁记录地址和状态00,表示由该线程给对象加锁。
  • 如果cas失败,有两种情况
    • 如果是其他线程已经持有了该Object的轻量级锁,这时表明有竞争,进入锁膨胀过程。
    • 如果是自己执行了synchronized锁重入,那么再添加一条Lock Record 作为重入的计数,新增的Lock Record的第一部分值为null。
  • 当退出synchronized代码块(解锁时)如果有取值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数减一。
  • 当退出synchronized代码块(解锁时)如果锁记录的值不为null,这时使用cas将Mark Word的值恢复给对象头。
    • 成功,则解锁成功。
    • 失败,说明轻量级锁进行了锁膨胀,或已经升级为重量级锁,进入重量级锁解锁流程。

8、锁膨胀

如果在尝试加轻量级锁的过程中,CAS操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。

static Object obj = new Object();
public static void method1() {
	synchronized(obj) {
		// 同步代码块
	}
}

以上面代码为例:

  • 当Thread-0进入方法时,优先使用轻量级锁,栈帧中锁记录关联obj的引用,并且使用CAS操作,和obj的对象头替换Mark Word信息。obj的对象头中则记录了当前锁的状态为00(轻量级锁);
  • 当Thread-1进入方法时,也是使用轻量级锁,栈帧中锁记录关联obj的引用,并且尝试使用CAS操作,和obj的对象头替换Mark Word信息,但是发现obj的对象头中已经记录了当前锁的状态为00,所以CAS失败。
  • 进入锁膨胀过程
    • 为obj对象申请Monitor锁,让obj的Mark Word指向重量级锁地址,并且最后两位变为10。
    • 然后自己进入Monitor的EntryList BLOCKED.
  • 当Thread-0退出同步代码块解锁时,使用cas将Mark Word的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照对象引用找到对象位置,然后获取对象头中记录的Monitor地址,设置Owner为null,唤醒EntryList中BLOCKED线程。

9、自旋优化

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这个时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。

因为锁竞争的时候,如果线程进入EntryList变为BLOCKED状态,线程会进行上下文切换,比较耗费性能的。所以引入了自旋,让线程重复试几次,避免阻塞,避免上下文切换。

  • jdk1.6之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋或不自旋,总之,比较智能。
  • 自旋会占用CPU时间,单核CPU自旋就是浪费,多核CPU自旋才能发挥优势。
  • jdk1.7之后,不能控制是否开启自旋功能。

10、偏向锁

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行CAS操作;

java6中引入了偏向锁来做进一步优化:只有第一次使用CAS将线程ID设置到对象的Mark Word头,之后发现这个线程ID是自己的就表示没有竞争,不用重新CAS。以后只要不发生竞争,这个对象就归该线程所有。

锁重入:指自己已经给该对象加锁,在调用其他方法的时候,又要给该对象加锁,在偏向锁之前,出现这种重入情况,是在栈帧中记录一个指为null的锁记录,用于重入计数。

偏向锁,对象头中Mark Word记录:不再记录hashcode,而是记录线程id。

一个对象创建时:

  • 如果开启了偏向锁(默认开启),那么对象创建后,markword值为0x05即最后3位为101,这时它的thread、epoch、age都为0。
  • 偏向锁默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加VM参数 -XX:BiasedLockingStartupDelay=0来禁用延迟。
  • 如果没有开启偏向锁,那么对象创建后,markword值为0x01,即最后3位为001,这时它的hashcode、age都为0,第一次用到hashcode时才会赋值。

对象的哈希码默认是0,当第一次调用对象的hashcode时,才会产生对象哈希码,并填充到对象的mark word头中。

当调用对象的hashCode时,会默认禁用偏向锁,或撤销偏向锁状态。

对象创建后,默认是偏向锁状态,等到有其他线程要获取对象锁的时候,如果时间是错开的,则偏向锁会升级为轻量级锁,如果时间不是错开,竞争关系,则会升级为重量级锁。

11、批量重偏向

因为默认是偏向锁,所以第一次有错开时间的其他线程来获取锁的时候,会撤销偏向锁,升级为轻量级锁。

但是撤销偏向,消耗也比较大。

如果对象被多个线程访问,但没有竞争,这时偏向了线程T1的对象仍有机会重新偏向T2,重偏向会重置对象的ThreadId;

当撤销偏向锁阈值超过20次后,jvm会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程。

12、批量撤销

当撤销偏向锁阈值超过40次后,jvm会觉得,自己确实偏向错了,根本就不应该偏向,于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的。

13、wait、notify原理

  • Owner中的线程发现自己条件不满足,于是调用wait方法,即可进入WaitSet变为WAITING状态。
  • BLOCKED和WAITING的线程都处于阻塞状态,但不占用CPU时间片。
  • BLOCKED线程会在Owner线程释放时被唤醒。
  • WAITING线程会在Owner线程调用notify或notifyAll时唤醒,但唤醒后并不意味着立刻获得锁,仍需进入EntryList重新竞争。
API介绍
  • wait()让进入object监视器的线程到waitSet等待。
  • wait(long timeout)等待timeout时间,如果没有被叫醒,则继续执行。
  • notify()在object上正在waitSet等待的线程中挑一个唤醒。
  • notifyAll()让object上正在waitSet等待的线程全部唤醒。
  • 都是Object类的方法,必须获得此对象的锁,才能调用这个方法。
sleep(long n)和wait(long n)的区别
  • sleep是Thread方法,而wait是Object的方法
  • sleep不需要强制和synchronized配合使用,但wait需要和synchronized一起使用。
  • sleep在睡眠的同时,不会释放对象锁,但wait在等待的时候会释放对象锁。
  • 它们状态都是TIME_WAITING

14、Park&Unpark

基本使用

它们是LockSupport类中的方法

// 暂停当前线程,谁调用,谁被暂停。
LockSupport.park();

// 恢复某个线程的运行
LockSupport.unpark(暂停线程对象);

先park在unpark

Thread t1 = new Thread(() -> {
	log.debug("start...");
	sleep(1);
	log.debug("park...");
	LockSupport.park();
	log.debug("resume...");
},"t1");
t1.start();

sleep(2);
log.debug("unpark...");
LockSupport.unpark(t1);
特点

与Object的wait&notify相比

  • wait、notify和notifyAll必须配合Object Monitor一起使用,而park&unpark不必。
  • park&unpark是以线程为单位来【阻塞】和【唤醒】线程,而notify只能随机唤醒一个等待线程,notifyAll是唤醒所有等待线程,就不那么【精确】
  • park&unpark可以先unpark再park,而wait&notify不能先notify
原理

每个线程都有自己的一个Parker对象,由三部分组成 _counter, _cond和 _mutex

打个比喻:

  • 线程就像一个旅客,Parker就像他随身携带的背包,条件变量就好比背包中的帐篷。_counter就好比背包中的备用干粮(0为耗尽,1为充足)
  • 调用park就是要看需不需要停下来休息
    • 如果备用干粮耗尽,进入帐篷休息
    • 如果备用干粮充足,不需停留,继续前进
  • 调用unpark,就好比令干粮充足
    • 如果这时线程还在帐篷,就唤醒它继续前进
    • 如果这时线程正在运行,那么下次他调用park时,仅是消耗备用干粮,不需停留继续前进
      • 因为背包空间有限,多次调用unpark仅会补充一份备用干粮。

15、活跃性

死锁

一个线程需要同时获取多把锁,就容易产生死锁。

t1线程获取A对象锁,接下来想获取B对象的锁

t2线程获取B对象锁,接下来想获取A对象的锁

解决方法:顺序加锁解决,但是容易产生饥饿问题。

活锁

活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束。

饥饿

16、ReentrantLock

相对于synchronized它具备如下特点

  • 可中断(这里说的可中断是指在等待锁的状态下可中断,并没有拿到锁)
  • 可以设置(等待锁的)超时时间
  • 可以设置为公平锁
  • 支持多条件变量
  • synchronized同步代码块执行完,会在字节码的层面解锁,而ReentrantLock必须手动unlock解锁。

与synchronized一样,都支持可重入。

基本语法:

ReentrantLock reentrantLock = new ReentrantLock(); // 其实reentrantLock就是普通对象+Monitor
// 获得锁
reentrantLock.lock();
try{
	// 临界区
} finally {
	// 释放锁
	reentrantLock.unlock();
}
可重入

可重入是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此有权利再次获取这把锁。

如果是不可重入锁,那么第二次获得锁时,自己也会被锁拦住。

可打断

尝试获得锁的等待状态,是可打断的。

lock.lockInterruptibly(); // 尝试获取锁,可以被其他线程调用interrupt()打断方法打断。

锁超时

lock.tryLock();

返回值Boolean类型,true则获取锁成功,false则获取锁失败。防止无限等待。

lock.tryLock(1,TimeUnit.SECONDS);

会等待最多1秒,如果1秒后,还没有获得锁,则返回false。

tryLock也会被打断。

公平锁

synchronized的Monitor就是不公平锁:当锁被一个线程占用,其他线程进入EntryList等待,当锁的持有者释放了锁,EntryList中的线程,不管谁先谁后,都会争抢这个锁,谁先抢到,谁就拥有锁。

ReentrantLock默认也是不公平锁。

// 默认是不公平锁,但是可以通过构造方法设置为公平锁。
// 设置为公平锁后,等待线程就会按照先进先出的队列获得锁。
public ReentrantLock(boolean fair){
	syn = fair ? new FairSync() : new NonfairSync(); // fair  公平的,合理的
}

// 公平锁一般没有必要,因为会降低并发度。
条件变量

synchronized中也有条件变量,但是不管什么条件,满足条件时,都是进入的同一个waitSet,调用notifyAll的时候,也是唤醒该锁的waitSet的所有线程。

ReentrantLock的条件变量支持多个条件变量,满足不同条件,可进入不同的Condition对象中。唤醒时也是针对不同条件进行唤醒。

使用流程:

  • 首先要获得锁
  • 调用await()方法,会释放锁,进入condition对象等待
  • await的线程被唤醒(或打断、或超时)去重新竞争锁
  • 竞争lock锁成功后,从await后继续执行。

Condition condition1 = lock.newCondition();

Condition condition2 = lock.newCondition();

condition1.await(); // 在哪个线程执行,哪个线程就会进入这个condition1对象(休息室)。

condition1.signal(); // 从condition1对象中随机唤醒一个

condition1.signalAll(); // 将condition1对象中全部唤醒。

二、JMM(Java内存模型)

前面一部分讲的Monitor主要关注的是访问共享变量时,保证临界区代码的原子性。

下面这部分,学习共享变量在多线程间的【可见性】问题与多条指令执行时的【有序性】问题

1、Java内存模型

JMM即Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着CPU寄存器、缓存、硬件内存、CPU指令优化等。

JMM体现在以下几个方面:

  • 原子性:保证指令不会受到线程上下文切换的影响。
  • 可见性:保证指令不会受到CPU缓存的影响。
  • 有序性:保证指令不会受CPU指令并行优化的影响。
主存

所有线程共享信息存储的位置

工作内存

线程私有的信息存储位置。

2、可见性

Thread t = new Thread(()-> {
    while (run) {

    }
});
t.start();
Thread.sleep(1000);
System.out.println("停止 t");
run = false;

以上代码应该在1s后停止,但是并没有。

因为t线程要频繁从主内存中读取run的值,JIT即时编译器会将run的值缓存到自己工作内存的高速缓存中,减少对主存中run的访问,提高效率。

1s后,main线程修改了run的值,并同步至主存,而t是从自己工作内存中的高速缓存中读取这个变量的值,所以结果永远是旧值。

解决方法

volatile (易变关键字)

它可以用来修饰成员变量和静态成员变量,可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作volatile变量都是直接操作主存。

synchronized和volatile都可以保证共享变量的可见性

但是synchronized需要创建Monitor对象,比较重量级,volatile相对比较轻量级。

可见性vs原子性

volatile保证的是在多个线程之间,一个线程对volatile变量的修改对另一个线程可见,但是不能保证原子性,仅用在一个写线程,其他线程读的情况。但是不能解决指令交错。(就是说,volatile变量,被修改后,后面线程读到的都是最新值,但是前面读到的值不变。)

注意:

synchronized语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是synchronized是属于重量级操作,性能相对更低。(因为一旦产生竞争,就会关联Monitor对象,升级为重量级锁)

如果在前面示例的死循环中加入**System.out.println()**会发现,即使不加volatile修饰符,线程t也能正确看到对run变量的修改。

System.out.println()也可以保证变量可见性。

在synchronized同步代码块里面的共享变量由synchronized保证可见性。如果不在同步块里面,要想保证多个线程的可见性,需要用volatile修饰。

3、终止模式之两阶段终止模式

错误的终止方法:

  • 使用线程对象的stop()方法停止线程
    • stop方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后,就再也没有机会释放锁,其他线程将永远无法获取锁。
  • 使用System.exit(int)方法停止线程
    • 目的仅是停止一个线程,但这种做法会让整个程序都停止。

正确的终止方法:

  • 使用线程的interrupt()方法设置打断标记,判断isInterrupted()是否为true,满足条件进入逻辑终止前的处理。
    • 但是这里要考虑到的是,如果线程正常运行被打断,那么是有打断标记的。
    • 如果线程在sleep或wait等的时候被打断,是会清除打断标记的,所以catch块中,要重新调用interrupt()方法设置打断标记。
  • 使用volatile修饰变量,作为判断是否打断的标记。
    • 如果变量被修改为false,则另一个线程根据可见性,读取到最新值,可判断线程已被打断,则进入处理终止前逻辑。
    • 如果有sleep或wait等的干扰,则改变volatile变量值的时候,调用interrupt()打断。

4、同步模式之Balking

Balking(犹豫)模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做了,直接返回结束。

经常用来实现线程安全的单例。

5、有序性

JVM会在不影响正确性的前提下,可以调整语句的执行顺序。这种特性称之为【指令重排】。

多线程下指令重排会影响正确性。

在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行。

指令重排的前提是,重排指令不能影响结果。

使用volatile可以禁止指令重排。

6、volatile原理

volatile的底层实现原理是内存屏障,(Memory Barrier/Memory Fence)

  • 对volatile变量的写指令后会加入写屏障。
  • 对volatile变量的读指令前会加入读屏障。
6.1、如何保证可见性
  • 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中。
public void actor2(I_Result r) {
	num = 2;
	ready = true; // ready是volatile赋值带写屏障。
	// 写屏障
}
  • 读屏障(lfence)保证在该屏幕之后,对共享变量的读取,加载的是主存中最新数据。
public void actor1(I_Result r) {
	// 读屏障
	// ready是volatile读取值带读屏障
	if(ready) {
		r.r1 = num + num;
	}else {
		r.r1 = 1;
	}
}
6.2、如何保证有序性
  • 写屏障会确保指令重排时,不会将写屏障之前的代码排在写屏障之后。
  • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前。

不能解决指令交错。

  • 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读到它前面去。
  • 而有序性的保证也只是保证了本线程内相关代码不被重排序。
6.3、double-checked locking问题(dcl)
// 双重校验、懒汉模式
public class Singleton {
	private Singleton() {}
	private static Singleton INSTANCE;
	// 添加volatile禁止指令重排,保证了单例的可靠性。
	// private static volatile Singleton INSTANCE;
	public static Singleton getInstance() {
		if(INSTANCE == null) {
			// 只有第一次访问会同步,而之后的使用没有synchronized
			synchronized(Singleton.class) {
                // 为什么要两次判空,因为首付初始化的时候,t1线程进入同步代码块,还未调用构造方法的时候,
                // t2线程进入get方法,首次判断为null,也在同步代码块等待,t1创建完毕,t2接着创建,所以需要判空。
				if(INSTANCE == null) {
					INSTANCE = new Singleton();
				}
			}
		}
		return INSTANCE;
	}
}

以上看似完美的懒汉单例,实际上多线程情况下,会有问题。可能会拿到没有初始化完毕的对象。

因为赋值操作和调构造方法的指令发生了指令重排,如果在构造方法中要执行很多初始化操作,那么t2线程拿到的将会是一个未初始化完毕的单例。

解决办法

对INSTANCE使用volatile修饰即可,可以禁用指令重排。JDK5以上的版本volatile才会有效。

synchronized可以保证共享变量的原子性,可见性,有序性。在synchronized代码块内部,也是可以指令重排的,但是如果共享变量不是被同步代码块完全包裹,则指令重排依然会出现问题,所以需要用volatile修饰共享变量,保证有序性,禁止指令重排。

静态内部类实现单例

public final class Singleton {
	private Singleton() {}
	// 静态内部类,对外不可见
	// 属于懒汉式,只有调用getInstance方法的时候,才会出发内部类的加载。内部类没有加载,内部的静态变量不会初始化。
	private static class LazyHolder {
		static final Singleton INSTANCE = new Singleton();
	}
	
	public static Singleton getInstance() {
		// 类加载时,对静态变量的赋值,由JVM保证线程安全性。
		return LazyHolder.INSTANCE;
	} 
}
  • 可见性-由JVM缓存优化引起
  • 有序性-由JVM指令重排优化引起

三、无锁-乐观锁(非阻塞)

1、CAS与volatile

无锁实现线程安全的方法。AtomicInteger内部没有用锁来保护共享变量的线程安全。

// 原子整数
private AtomicInteger balance;
// 对余额进行减法
public void withdraw(Integer amount) {
	// 如果修改失败,则重复执行,知道修改成功
	while(true) {
		// 获取当前的余额
		int prev = balance.get();
		// 计算修改后的值
		int next = prev - amount;
		// 比较获取到的值是否有变化,如果有变化,修改失败,如果没有变化,更新新的值
		if(balance.compareAndSet(prev,next)) {
			break;
		}
	}
	
	// 简化后的代码
	// balance.getAndAdd(-1 * amount);
}

其中关键是compareAndSet,它的简称就是CAS(也可以叫Compare And Swap),它必须是原子操作。

2、volatile

获取共享变量时,为了保证该变量的可见性,需要使用volatile修饰。

它可以用来修饰成员变量和静态成员变量,可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作volatile变量都是直接操作主存,即一个线程对volatile变量的修改,对另一个线程可见。

注意

volatile仅仅保证了共享变量的可见性,让其他线程能够看到最新值,但不能解决指令交错问题(不能保证原子性),cas保证原子性。

CAS必须借助volatile才能读取到共享变量的最新值来实现【比较并交换】的效果。

3、cas效率

为什么无锁效率高

无锁情况下,即使重试失败,线程始终在高速运行,没有停歇,而synchronized会让线程在没有获得锁的时候发生上下文切换,进入阻塞。

当线程数小于cpu核心数,cas效率高。

3.1、CAS的特点

结合CAS和volatile可以实现无锁并发,适用于线程数少,多核CPU的场景下。

  • CAS是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,再重试即可。
  • synchronized是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,上了锁其他线程都改不了,释放锁才有机会。
  • CAS体现的是无锁并发、无阻塞并发。
    • 因为没有使用synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一。
    • 但如果竞争激烈,可以想到重试必然频繁发生,反而会影响效率。

4、原子类型(Atomic)

4.1、原子整数类型

AtomicBoolean

AtomicInteger

AtomicLong

AtomicInteger i = new AtomicInteger(0);
i.incrementAndGet();// ++i 自增+1 会保证整体的原子性,线程安全,底层使用compareAndSet方法。
i.getAndIncrement();// i++ 自增+1

i.decrementAndGet();// --i 递减1

i.getAndAdd(5);// 先获取,在相加

// 此处回忆Lambda用法。

4.2、原子引用类型

保证引用类型的数据的原子操作。

AtomicReference

AtomicMarkableReference

AtomicStampedReference

4.3、原子数组类型

AtomicIntegerArray

AtomicLongArray

AtomicReferenceArray

4.4、字段更新器

AtomicReferenceFiledUpdater

AtomicIntegerFieldUpdater

AtomicLongFieldUpdater

利用字段更新器,可以针对对象的某个域(field)进行原子操作。只能配合volatile修饰的字段使用,否则会出现异常,因为CAS需要个volatile配合使用。

4.5、原子累加器

原子累加器LongAdder

虽然AtomicLong也可以保证线程安全的进行累加,但是性能要比LongAdder差一些。

性能提升的原因,在有竞争时,设置多个累加单元,Thread-0累加Cell[0],而Thread-1累加Cell[1],最后将结果汇总。这样他们在累加时操作不同的Cell变量,因此减少了CAS重试失败,从而提高性能。

Cell为累加单元

4.6、CAS锁

通过CAS和volatile实现CAS加锁

4.7、伪共享

Cell为累加单元

// 防止缓存行伪共享
@sun.misc.Contended
static final class Cell {
    volatile long value;
    Cell(long x) {value = x;}
    
    // 最重要的方法,用来CAS方式进行累加,prev标识旧值,next表示新值
    final boolean cas(long prev,long next) {
        return UNSAFE.compareAndSwapLong(this,valueOffset,prev,next);
    }
    // 其余代码省略
}

LongAdder源码解析

5、CAS的ABA问题

主线程仅能判断共享变量的值与最初值A是否相同,不能感知到从A改为B又改回A的情况。(AtomictReference)

如果想要知道有其他线程动过了共享变量,则需要加一个版本号。(AtomicStampedReference)

6、Unsafe

Unsafe对象提供了非常底层的,操作内存、线程的方法,Unsafe对象不能直接调用,只能通过反射获得。

四、不可变

1、不可变的使用

1.1、SimpleDateFormat

线程不安全,当多个线程同时调用parse方法时,会报错。

解决办法,使用synchronized关键字,但是会影响性能,依靠的是互斥的原理。

1.2、DateTimeFormatter

线程安全的时间格式类,不可变且线程安全,是jdk1.8引入的日期类。final修饰

DateTimeFormatter stf = DateTimeFormatter.ofPattern("yyyy-MM-dd");
for(int i = 0;i < 10;i ++) {
	new Thread(() -> {
		TemporalAccessor parse = stf.parse("1999-12-12");
		System.out.println(parse);
	}).start();
}

2、不可变的设计

3、包装类

Byte、Short、Long缓存的范围都是-128~127

Character缓存的范围是0~127

Integer的默认范围是-128~127,最小值不能变,但最大值可以通过调整虚拟机参数 -Djava.lang.Integer.IntegerCache.high来改变。

Boolean缓存了TRUE和FALSE

这些包装类提供了valueOf方法,在这个范围内会重用对象,大于这个范围,才会新建对象。

4、final的原理

如果共享变量用final修饰,那么它的值在栈中,效率要高。

如果共享变量没有用final修饰,那么它的值相当于在堆中,相对较慢一些。

五、JDK线程池

1、ThreadPoolExecutor

顶层接口:Executor

派生接口:ScheduledExecutorService

子类:ThreadPoolExecutor

上面两个的子类:ScheduledThreadPoolExecutor

1.1、线程池状态

ThreadPoolExecutor使用int的高3位来表示线程池状态,低29位表示线程数量。

  • RUNNING 111
  • SHUTDOWN 000 不会接收任务,但会处理阻塞队列剩余任务
  • STOP 001 会中断正在执行的任务,并抛弃阻塞队列任务
  • TIDYING 010 任务全执行完毕,活动线程为0,即将进入终结
  • TERMINATED 011 终结状态

这些信息存储在一个原子变量ctl中,目的是将线程池状态与线程个数合二为一,这样可以用一次cas原子操作进行赋值。

1.2、构造方法
public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUtil unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)
{...}
  • corePoolSize:核心线程数量
  • maximumPoolSize:最大线程数量
  • keepAliveTime:空闲时最大存活时间,针对救急线程
  • unit:时间单位
  • workQueue:阻塞队列
  • threadFactory:线程工厂,可以为线程创建时起名字
  • handler:拒绝策略

线程池中的线程是懒加载的,没有任务的时候,是没有线程的。

当第一次接收到任务的时候,会创建核心线程执行任务。

如果核心线程已满,还有任务进来,则进入阻塞队列排队。

如果此时继续有任务进来,则会最多创建(最大线程数-核心线程数)个线程来执行任务。(此处针对有界队列)

如果此时还是继续有任务进来,则会执行拒绝策略。

当高峰期过去,超过corePoolSize的救急线程如果一定时间内没有任务做,则会关闭该线程,节约资源,但是核心线程会一直运行。

拒绝策略

  • AbortPolicy 让调用者抛出RejectedExecutionException异常,这是默认策略
  • CallerRunsPolicy 让调用者运行任务
  • DiscardPolicy 放弃本次任务
  • DiscardOldestPolicy 放弃队列中最早的任务,本任务取而代之。

2、线程池工具类Executors

2.1、固定线程池 newFixedThreadPool
public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads,nThreads,0,TimeUnit.MILLISECONDS,
                          new LinkedBlockingQueue<Runnable>());
}

特点:

  • 核心线程数 = 最大线程数(没有救急线程),因此也无需超时时间
  • 阻塞队列是无界的,可以放任意数量的任务。
  • 适用于任务量已知,相对耗时的任务。
2.2、带缓冲功能线程池 newCachedThreadPool
public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0,Integer.MAX_VALUE,60L,TimeUnit.SECONDS,
                                 new SynchronousQueue<Runnable>());
}

特点:

  • 核心线程数为0,最大线程数是Integer.MAX_VALUE,救急线程的空闲生存时间是60s,意味着:
    • 全部都是救急线程(60s后可以回收)
    • 救急线程可以无限创建
  • 队列采用了SynchronousQueue实现,特点是,它没有容量,没有线程来取,是放不进去的。
  • 整个线程池表现为线程数会根据任务量不断增长,没有上限,当任务执行完毕,空闲1分钟后释放线程。
  • 适合任务数比较密集,但每个任务执行时间较短的情况。
2.3、单线程线程池 newSingleThreadExecutor
public static ExecutorService newSingleThreadExecutor() {
    return new FinalizableDelegatedExecutorService(
        new ThreadPoolExecutor(1,1,0L,TimeUnit.MILLISECONS,
                                 new LinkedBlockingQueue<Runnable>()));
}

使用场景:

希望多个任务排队执行。线程数固定为1,任务数多于1时,会放入无界队列排队。任务执行完毕,这唯一的线程也不会被释放。

区别:

  • 自己创建一个单线程串行执行任务,如果任务执行失败而终止,那么没有任何补救措施,而线程池还会新建一个线程,保证池的正常工作。
  • Executors.newSingleThreadExecutor()线程个数始终为1,不能修改
    • FinalizableDelegatedExecutorService应用的时装饰器模式,只对外暴露了ExecutorService接口,因此不能调用ThreadPoolExecutor中特有的方法。
  • Executors.newFixedThreadPool(1)初始时为1,以后还可以修改
    • 对外暴露的是ThreadPoolExecutor对象,可以强转后调用setCorePollSize等方法进行修改。
2.4、任务调度线程池 newScheduledThreadPool
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
    return new ScheduledThreadPoolExecutor(coorPoolSize);
}


// ScheduledThreadPoolExecutor方法:
// 延迟一定时间执行
public <V> ScheduledFuture<V> schedule(Callable<V> callable,long delay,TimeUnit unit) {
    ...
}
// 延迟一定时间并每次执行开始间隔period时间
public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,
                                                  long initialDelay,
                                                  long period,
                                                  TimeUnit unit){
    ...
}
// 延迟一定时间并每个任务间隔delay时间
public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command,
                                                     long initialDelay,
                                                     long delay,
                                                     TimeUnit unit) {
    ...
}
2.5、提交任务
// 执行任务
void execute(Runnable command); // 由ThreadPoolExecutor实现

// 提交任务task,用返回值Future 获得任务执行结果 
<T> Future<T> submit(Callable<T> task); // 由抽象类AbstractExecutorService实现

// 提交tasks中所有任务  --由抽象类AbstractExecutorService实现
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks) throws InterruptedException;

// 提交tasks中所有任务,带超时时间  --由抽象类AbstractExecutorService实现
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks,long timeout,TimeUnit unit) throws InterruptedException;

// 提交tasks中所有任务,哪个任务先成功执行完毕,返回此任务执行结果,其他任务取消  --由抽象类AbstractExecutorService实现
<T> T invokeAny(Collection<? extends Callable<T>> tasks) throws InterruptedException,ExecutionException;

// 提交tasks中所有任务,哪个任务先成功执行完毕,返回此任务执行结果,其他任务取消,带超时时间  --由抽象类AbstractExecutorService实现
<T> T invokeAny(Collection<? extends Callable<T>> tasks,long timeout,TimeUnit unit) throws InterruptedException,ExecutionException,TimeoutException;
2.6、execute和submit的区别
  • 接受的参数不同,execute只能接收Runnable类型的参数,submit可以接收Runnable类型也可以接收Callable类型的参数。
  • submit有返回值,而execute没有。没有返回值,可以执行任务,但是无法判断任务是否成功完成。
    • 当submit参数为Runnable时,返回的Future对象get()方法得到的是null;
    • 如果参数为Callable,返回的Future对象get()方法得到的是Callable的call方法返回值。如果抛出异常,则get到的是异常信息。
  • submit方便Exception处理。
    • execute发生已成会直接抛出
    • submit不会直接抛出异常,通过Callable和Future的组合,异常信息可以future.get()得到。
2.7、关闭线程池
  • void shutdown()
    • 线程池状态变为SHUTDOWN
    • 不会接收新任务
    • 但已提交的任务会执行完
    • 此方法不会阻塞调用线程的执行
  • List shutdownNow()
    • 线程池状态变为STOP
    • 不会接收新任务
    • 会将队列中的任务返回
    • 并用interrupt的方式中断正在执行的任务
2.8、创建多少线程池合适
  • 过小会导致程序不能充分的利用系统资源、容易导致饥饿
  • 过大会导致更多的线程上下文切换,占用更多内存
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值