一.线程基本概念
1.为啥要有线程
(1)并发编程,随着多核cpu的发展成为刚需。
(2)多进程虽然也可以实现并发,但是进程的创建和销毁,太重量了。
2.线程和进程的区别和联系
(1)进程太重量了,创建、调度、销毁的速度慢,都会有很大的开销。线程比较轻量,创建、调度、销毁的速度快,开销比进程少很多。
(2)进程包含线程,一个进程可以包含一个线程,也可以包含多个线程。
(3)一个线程启动的时候,开销比较大一点,后续的线程开销就比较小。
(4)多进程之间,数据是分开的,共享复杂,同步简单;多线程之间,数据是共享的,共享简单,同步困难。
(5)多进程占用内存多,切换困难,CPU利用率低;多线程占用内存少,切换容易,CPU利用率高。
(6)进程之间不会互相影响;一个线程挂掉可能导致整个进程挂掉。
3.线程的使用
(1)线程创建
a.集成Thread类,重写run方法。
b.实现Runnable接口,重写run方法。
c.继承Thread,重写run方法,使用匿名内部类。
d.实现Runnable接口,重写run方法,使用匿名内部类。
e.使用Lambda表达式。
(2)Thread的常用属性/方法
ID getId()
名称 getName()
状态 getState() 线程状态
是否后台(守护)线程 IsDaemon()
前台线程,会阻止线程结束,前台线程的工作没做完,进程是完不了的。
后台进程,不会组织进程结束,后台线程没有结束,也是可以结束进程的。
是否存活 IsAlive()
是否被中断 IsInter
(3)线程的终止/中断
a.使用自己创建的标志位。
b.Thread提供了内置的标志位,可以使用isInterruptted方法判断标志位,使用Interrupt来设置标志位(还能把线程从休眠中唤醒)。
备注:对于终止,不是线程t1调用线程t2的Interrupt,只是t1通知t2要结束了,是否结束取决于t2本身。
(4)线程等待
join方法
在t1线程调用t2的join方法,就是让t1等待t2执行完了,再继续执行。
(5)获取线程引用
Thread.currentThread()
谁调用这个方法,就能获取到哪个线程的引用。
(6)休眠线程 sleep
本质上是让当前调用这个方法的线程,暂时不参与系统的调度执行(把这个线程的PCB放到了一个表示阻塞状态的队列,等到sleep时间到,操作系统会把这个PCB拿回到就绪队列)。
4.线程状态
本质是为了支撑线程调度的实现,java中的线程状态与操作系统不同。
NEW Thread对象有了,但是内核的PCB还没有(还没有调用start方法)。
TERMINATED 内核的PCB没有了(线程执行完),Thread对象还在。
RUNNABLE 就绪+运行状态(线程正在CPU运行 + 线程在排队即将到CPU执行)。
WAITING wait方法触发的方法阻塞。
TIMED_WAITING sleep触发的线程阻塞。
BLOCKED synchronized触发的线程阻塞。

创建线程后NEW状态,调用start()方法,线程转为RUNNABLE 态。
Runnable态调用sleep方法进入TIMED_WAITING态,sleep时间到进入RUNNABLE 态。
Runnable态调用wait方法/join方法进入WAITING态,调用notify后进入RUNNABLE 态。
线程获取锁,未抢占上时,进入阻塞BLOCKED态,获取到锁后进入RUNNABLE 态。
线程执行完成后,进入TERMINATED态。
5.线程安全
程序在多线程环境下,不出问题,就可以视为线程安全。
反之,程序在单线程和多线程环境下运行结果不一致,就为线程不安全。
线程不安全的原因:
(1)根本原因,线程是抢占式执行,随机调度的。
(2)多个线程同时修改同一个变量。
(3)修改操作,不是原子性。
典型就是++
a.load操作,把内存数据读取到CPU寄存器。
b.add操作,在寄存器+1。
c.save操作,把寄存器的内容写回到内存。
把修改行为变为原子操作,就是解决线程安全问题的关键方法。比如:synchronized。
(4)内存可见性
一个线程读,一个线程写,频繁读取同一个内存变量,就可能触发编译器的优化,后续的读取内存,被优化为直接读取寄存器。
如果另一个线程对这个内存变量进行写操作,读取的线程可能不能及时感知到变化。
解决办法为用volatile修饰这个变量,此时这个变量就不会优化,每次读取都是从内存中读取。
(5)指令重排序
本质上也是编译器优化的问题。
在单例模式中,就有一个典型的例子。

如果时按照1、3、2在多线程情况下执行,有可能执行1、3之后,切换到其他线程,其他线程就会得到一个非空的instance引用,但指向的对象是一个不完整的对象。
注意:加锁后,不是线程一直在cpu上,但是切换调度照常,只是其他线程尝试加锁,就会阻塞。
6.synchronized
加锁,核心是把一组不是原子的操作,变成了“原子”操作,解决线程安全问题的核心手段。
理解“锁对象”
必须是两个线程针对同一个锁对象进行加锁,才会产生阻塞等待。
死锁问题
(1)一个线程一把锁,连续加锁两次,如果锁是不可重复锁,就会死锁,但是synchronized是可重入锁。
(2)两个线程,两把锁,线程1获取锁A,线程2获取锁B,此时线程1尝试获取B,线程2尝试获取A。
(3)N个线程,M把锁,典型例子:哲学家就餐问题。
如何避免死锁?
一种简单有效的方法,打破循环等待,针对锁进行编号,并且要求按照固定顺序加锁。
7.volatile
内存可见性问题,静止指令重排序。
8.wait notify
控制线程之间的执行顺序。
搭配synchronized,wait要做的事情,释放锁,等待notify通知,收到通知之后重新获取锁(注意:notify是随机唤醒)。
二.阻塞队列
1.什么是阻塞队列
阻塞队列,也是一个队列,先进先出。
阻塞队列,是特殊队列,虽然也是先进先出,但是带有特殊的功能:阻塞。
(1)如果队列为空,执行出队列操作,就会阻塞,阻塞到另一个线程往队列添加元素(队列不空)为止。
(2)如果队列满了,执行入队列操作,也会阻塞,阻塞到另一个线程从队列取走元素(队列不满)为止。
特殊的队列,不一定遵守先进先出,比如优先级队列,PriorityQueue。
2.消息队列
消息队列是什么
消息队列,也是特殊队列,相当于在阻塞队列的基础上,加上“消息的类型”,按照制定类别进行先进先出。
此时谈到的消息队列仍然是一个“数据结构”,因为“消息队列”太香,有大佬把这样的“数据结构”单独实现成了一个程序。这个程序可以通过网络的方式和其他程序通信。
这时,这个消息队列就可以单独部署到一组服务器(分布式)。
存储能力和转发能力都大大提升,很多大型项目都能看到这样消息队列的身影。
此时,这个消息队列,就已经成了可以和mysql、redis相提并论的“中间件”了。
rabbit mq、active mq、rocket mq、kafka.......这些都是业界知名的消息队列。
为什么消息队列这么香?
和阻塞队列的阻塞特性关系非常大,基于这样的特性,可以实现“生产者消费者模型”。
什么是生产者消费者模型呢?
例如包饺子有两种方式。
两个人,每个人擀一个饺子皮,包一个饺子。此时两个人就会对擀面杖进行抢占。
一个人擀饺子皮,一个人包饺子。擀饺子皮的就是生产者,包饺子的就是消费者。
生产者消费者模型给程序带来两个特别大的好处。
(1)实现了发送方和接收方之间的“解耦”。低耦合就是一个模块改动对另一个模块影响不大,高耦合就是一个模块改动后,对另一个模块影响很大。写代码追求的就是“低耦合”。解耦就是降低耦合的过程。
开发中的经典场景:服务器之间的相互调用。

此时A服务器把请求转发给了B处理,B处理完了把结果反馈给A,此时就可以视为A调用了B。
这里的耦合度高,A服务器要调用B服务器,A必须知道B的操作,如果B服务器挂掉,很容易引起A服务器的bug。
如果此时添加一个C服务器,A服务器就需要添加不少代码。

针对上述场景,使用生产者消费者模型就可以有效的降低耦合。

此时,A和B之间的耦合就降低了很多,A不知道B,A只知道队列(A中没有任何与B相关的代码),B也是如此。如果B服务器挂掉,对A服务器没有任何影响,因为队列没有受到影响,A依然可以给队列插入元素。如果队列满,就先阻塞。如果A服务器挂掉,对B服务器没有任何影响,因为队列没有受到影响,B依然可以给队列取元素。如果队列空,就先阻塞。
如果需要添加C,让C服务器作为消费者,从队列取元素,A服务器依然时没有什么感知的。
(2)可以做到“削峰填谷”,保证系统的稳定性。

让系统趋于稳定状态。
3.使用和实现消息队列
(1)使用标准库提供的阻塞队列


ArrayBlockingQueue 基于数组的阻塞队列
LinkedBlockingQueue 基于链表的阻塞队列
PriorityBlockingQueue 带有优先级的阻塞队列(基于“堆”实现的既有阻塞功能又带有优先级功能)
普通的队列Queue提供的主要方法有三个:
a.入队列,offer
b.出队列,poll
c.取队首元素,peek
阻塞队列提供的主要方法有两个:
a.入队列,put
b.出队列,take
都带有阻塞功能
public static void main(String[] args) throws InterruptedException {
BlockingQueue<String> blockingQueue = new LinkedBlockingQueue<>();
blockingQueue.put("hello");
String take = blockingQueue.take();
System.out.println(take);
String take2 = blockingQueue.take();
System.out.println(take);
}
使用阻塞队列实现生产则消费者
public static void main(String[] args) {
//创建阻塞队列
BlockingQueue<Integer> blockingQueue = new LinkedBlockingQueue<>();
//创建两个线程作为生产者和消费者
//生产者
Thread customer = new Thread(() -> {
while (true){
try {
Integer result = blockingQueue.take();
System.out.println("消费元素:" + result);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
customer.start();
//消费者
Thread producer = new Thread(() -> {
int count = 0;
while(true){
try {
blockingQueue.put(count);
System.out.println("生产元素:" + count);
count++;
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
producer.start();
}
(2)编写简单的阻塞队列,暂不考虑泛型
实现一个阻塞队列,要先写一个普通队列。可以用数组也可以用链表。
基于链表实现,使用头删和尾插。一个链表的头删,时间复杂度是O(1);链表的尾插,时间复杂度可以是O(1),用一个额外的引用来记录当前尾节点。
基于数组实现,也叫做环形队列。默认情况下,这个数组的每个元素虽然已经开辟了内存空间,但是视为上面的元素是无效元素。创建两个下标[head,tail),head和tail指向0号元素,添加一个元素tail往后走一位,如果到数组边界则指头部元素,头删时head向后走一位。head和tail重合时,可能是空,也可能是满。
区分空和满的方法
a.浪费一个元素。
b.引入一个size,来记录个数。
基于数组实现阻塞队列。
package thread;
class MyBlockingQueue{
private int[] items = new int[1000];
private int head = 0;
private int tail = 0;
private int size = 0;
//入队列
public void put(int value) throws InterruptedException {
synchronized (this){
while (size == items.length){
//队列满了,产生阻塞
this.wait();
}
items[tail] = value;
tail++;
//针对tail处理
// tail = tail % items.length;
//%操作效率更低,可读性更差
if (tail >= items.length){
tail = 0;
}
size++;
this.notify();
}
}
//出队列
public Integer take() throws InterruptedException {
int result = 0;
synchronized (this){
while (size == 0){
//队列空,阻塞
this.wait();
}
result = items[head];
head++;
if (head >= items.length){
head = 0;
}
size--;
this.notify();
}
return result;
}
}
public class ThreadDemo23 {
public static void main(String[] args) {
MyBlockingQueue myBlockingQueue = new MyBlockingQueue();
Thread customer = new Thread(() -> {
while(true){
try {
int result = myBlockingQueue.take();
System.out.println("消费:" + result);
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
customer.start();
Thread product = new Thread(() -> {
int count = 0;
while (true){
try {
System.out.println("生产:" + count);
myBlockingQueue.put(count);
count++;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
product.start();
}
}
4.定时器
指定特定时间段之后,执行代码。
在网络编程中,出现“卡了”、“连不上”的情况,可以使用定时器“止损”。
和阻塞队列类似,java标准库也提供了定时器。
public static void main(String[] args) {
System.out.println("程序启动");
//标准库定时器
Timer timer = new Timer();
//shedule 安排 效果是给定时器注册一个任务,不会立即执行,而是在一段规定的时间后
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("运行定时器任务");
}
},3000);
}
自己编写定时器,满足以下条件。
(1)让被注册的任务能够在指定时间执行。单独在定时器内部创建一个线程,让这个线程周期性的扫描,判定任务是否是到时间了。
(2)一个定时器是可以注册N个任务的,N个任务会按照最初约定的时间,按顺序执行。N个任务需要使用数据结构保存。当前场景,使用优先级队列是最优选择。时间小的作为优先级高的,队首元素就是这个队列中最先要执行的任务,此时,扫描线程只需要扫描队首元素即可,不需要遍历整个队列。
(3)优先级队列会在多线程环境下使用,需要关注线程安全。推荐使用java标准库提供的:PriorityBlockingQueue 带有优先级的阻塞队列。
//用这个类表示保存的任务
class MyTask implements Comparable<MyTask>{
//要执行的任务内容
private Runnable runnable;
//任务啥时候执行(用毫秒时间戳表示)
private long time;
public MyTask(Runnable runnable, long time) {
this.runnable = runnable;
this.time = time;
}
//获取当前任务时间
public long getTime() {
return time;
}
//执行任务内容
public void run() {
runnable.run();
}
@Override
public int compareTo(MyTask o) {
//this比o小,返回<0
//this比o大,返回>0
//this和o相等,返回0
//当前要实现的,是队首元素是时间最小的
return (int)(this.time - o.time);
}
}
class MyTimer{
//扫描线程
private Thread t = null;
//阻塞优先级队列,保存任务
private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
public MyTimer(){
t = new Thread(() -> {
//如果时间没到,就会一直重复取出来塞回去,称为”忙等“
while (true){
//取出队首元素,检查是否到时间,如果时间没到就塞回队列,如果时间到了就进行执行
try {
MyTask myTask = queue.take();
long curTime = System.currentTimeMillis();
if (curTime < myTask.getTime()){
//未到时间,不必执行。
queue.put(myTask);
//在put之后进行一个wait
synchronized (this){
this.wait(myTask.getTime() - curTime);
}
} else {
//时间到了,执行任务
myTask.run();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
//指定两个参数
//1.任务内容
//2.任务在多少毫秒之后执行
public void schedule(Runnable runnable, long ofter){
//时间换算
MyTask task = new MyTask(runnable,System.currentTimeMillis() + ofter);
queue.put(task);
synchronized (this){
this.notify();
}
}
}
三.线程池
1.什么是线程池?
当我们需要创建销毁线程的时候,发现开销还是比较大的
为了进一步提高效率:
(1)搞一个“轻量级线程”=》协程/纤程(目前未加入java标准库)
(2)使用线程池,降低创建/销毁线程的开销
线程池内部创建了一些线程,使用的时候拿出来使用,不使用时还回去。这两个操作比操作系统创建和销毁线程的开销小很多。
创建/销毁线程需要操作系统内核操作。“拿”和“还”代码就可以实现。
2.使用线程池
在java标准库中,提供了线程池。

创建拥有10个线程的线程池
public class Demo {
public static void main(String[] args) {
//创建了一个线程池,线程数目固定十个
//工厂模式:使用普通方法,代替构造方法,创建对象
ExecutorService pool = Executors.newFixedThreadPool(10);
for (int i = 0; i < 1000; i++) {
int n = i;
//i是主线程main的局部变量,随着代码块的结束而销毁
//run方法属于Runnable,方法的执行的时机,是未来的某个节点(线程池的队列中排到他,他就执行)
//有可能for循环结束了,线程池队列还没有排完,此时i已经销毁了
//为了避免作用域的差异,于是就有了变量捕获,让run方法把刚才主线程的i给给往当前run的栈上拷贝一份
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello" + n);
}
});
}
}
}
运行程序后发现,main线程结束了,但是整个进程没有结束。线程池内的线程都是前台线程,会阻止进程结束(定时器Timer也是如此)。
java标准库的线程池有很多种。
Executors.newCachedThreadPool()
线程数量动态变化。如果任务多了,就多增加几个线程;如果任务少了,就少搞几个线程。
Executors.newFixedThreadPool()
创建固定数目的线程池。
Executors.newSingleThreadExecutor()
线程池里面只有一个线程。
Executors.newScheduledThreadPool()
类似于定时器,也是让任务延时执行,只不过执行的时候不是由扫描线程自己执行,而是由单独的线程自己执行了,而是由单独的线程池来执行。
3.什么是ThreadPoolExecutor?
上述线程池,本质上都是通过包装ThreadPoolExecutor来实现的。
这个类在java.util.concrrent包,简称juc,Java并发包。

corePoolSize 核心线程。
maximumPoolSize 最大线程数。
keepAliveTime 其他线程可以没有任务的最大时间。
unit 时间单位(s,ms)。
workQueue 线程池的任务队列。
threadFactory 线程工厂,用于创建线程。
handler 描述了线程池的拒绝策略,也是一个特殊对象,如果线程6任务满了,继续添加任务会有啥样行为。

标准库提供的四个拒绝策略。
第一个拒绝策略:如果任务太多,队列满了,直接抛出异常。
第二个拒绝策略:如果队列满了,多出来的任务谁加了,谁加了,谁负责执行。
第三个拒绝策略:如果队列满了,丢弃最早的任务。
第四个拒绝策略:丢弃最新的任务。
ThreadPoolExecutors相当于把线程分为两类,一类是核心线程,一类是其他线程,两种之和为最大线程数。允许核心线程无任务,其他线程没有任务时间长了就会被销毁。整体策略是,核心线程保底,其他线程动态调整。
实际开发中,线程池的线程数设定:
不同程序特定不同,设置的线程数也是不同的。
考虑两个极端情况。
(1)CPU密集型,每个线程要执行的任务都是狂转CPU(进行一系列算数运算),此时线程池线程数,最多也不应该超过CPU核数。设置的更多也没有。CPU密集型,一直占用CPU,CPU的坑已经满了。
(2)IO密集型,每个线程干的事情就是等待IO(读写硬盘,读写网卡,等待用户输入),不吃CPU。此时这样的线程会处于阻塞状态,不参与CPU调度,线程可以多一点,不受制于CPU。
然而实际开发中,没有程序符合两种理想型。真实的程序,往往一部分要吃CPU,一部分要等待IO。实践中,通过测试/实验的方式。
4.自己写一个线程池
写的线程池是固定数目的线程池。
一个线程池内部至少有两部分:
(1)阻塞队列,保存任务。
(2)若干个工作线程。
class MyThreadPool{
//任务
private BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();
//线程数目
public MyThreadPool(int n){
//创建n个线程
for (int i = 0; i < n; i++) {
Thread t = new Thread(() -> {
while (true){
try {
Runnable runnable = queue.take();
runnable.run();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
}
//注册任务给线程池
public void submit(Runnable runnable){
try {
queue.put(runnable);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public class Demo {
public static void main(String[] args) {
MyThreadPool pool = new MyThreadPool(1000);
for (int i = 0; i < 1000; i++) {
int n = i;
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello" + n);
}
});
}
}
}
四.常见锁策略
- 乐观锁和悲观锁
乐观锁:预测竞争不是很激烈(做的工作可能相对更少)。
悲观锁:预测竞争会很激烈(做的工作可能相对更多)。
背后做的工作是截然不同的。
这里不绝对,悲观和乐观唯一的区别就是,看预测锁竞争激烈程度的结论。
2.轻量级锁和重量级锁
轻量级锁:加锁解锁开销比较小,效率更高。多数情况下,乐观锁,也是一个轻量级锁(不能完全保证)。
重量级锁:加锁解锁开销比较多,效率更低。多数情况下,悲观锁,也是一个重量级锁(不能完全保证)。
3.自旋锁和挂起等待锁
自旋锁,是一种典型的轻量级锁。
挂起等待锁,是一种典型的重量级锁。
4.互斥锁和读写锁
互斥锁:类似synchronized,提供加锁和解锁操作,如果一个线程加锁了,另一个线程也尝试加锁,就会阻塞等待。
读写锁:提供了三种操作:针对读加锁;针对写加锁;解锁。
多线程针对同一个变量并发读,这个时候没有线程安全操作,没有线程安全问题,也不需要加锁操作。
读锁和读锁之间,没有互斥;写锁和写锁之间,存在互斥;写锁和读锁之间,存在互斥。
在java标准库提供了读写锁的具体实现(两个类,读锁类,写锁类)。
5.公平锁和非公平锁
此处把公平定义为“先来后到”。
公平锁:当解锁后,就按照队列中,最早等待的加锁。
非公平锁:当解锁后,线程一起竞争。
操作系统和java的synchronized 原生都是“非公平锁”。
操作系统针对加锁的控制,本身就是依赖于线程调度顺序的,这个调度顺序是随机的,不会考虑线程等待多久了。
6.可重入锁和不可重入锁
不可重入锁:一个线程针对一把锁,连续加锁两次,出现死锁。
可重入锁:一个线程针对一把锁,连续加锁多次不会触发死锁。
7.CAS
CAS全称Compare and swap,字面意思:比较并交换。
假设内存中存在原数据V(变量),寄存器内部有A、B两个值。
比较V和A,如果相等,就将V的值与B的值交换。
上述CAS的过程,并非通过一段代码实现的,而是通过一条CPU指令实现的,具有原子性。
(1)实现原子类:java标准库提供的类。原子类这里的实现,每次修改之前,确认一下是不是要修改的值。
package thread;
import java.util.concurrent.atomic.AtomicInteger;
public class Demo {
public static void main(String[] args) throws InterruptedException {
//这些原子类,基于CAS实现了自增自减等操作,此时这些操作不需要加锁,也是线程安全的。
AtomicInteger count = new AtomicInteger(0);
//使用原子类,来解决线程安全问题
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
//因为java不支持运算符自增自减,所以使用方法
count.getAndIncrement();//相当于count++
// count.incrementAndGet();//相当于++count
// count.getAndDecrement();//相当于count--
// count.decrementAndGet();//相当于--count
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
count.getAndIncrement();//相当于count++
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count.get());
}
}
(2)实现自旋锁
五.synchronized
1.synchronized的锁策略
(1)默认是一个乐观锁,如果发现锁竞争激烈就变成悲观锁,如果不激烈就变回乐观锁。
(2)默认是一个轻量级锁,如果发现锁竞争激烈就变成重量级锁,如果不激烈就变回轻量级锁。
(3)轻量级锁基于自旋锁实现;重量级锁基于挂起等待锁实现。
(4)不是读写锁。
(5)是非公平锁。
(6)是可重入锁。
2.synchronized的优化机制
(1)锁升级/膨胀
无锁->偏向锁->轻量级锁->重量级锁
进行加锁的时候,首先会进入偏向锁状态,并不是真正的加锁,只是占个位置,有需要再真加锁,没有需要就算了。
偏向锁这个过程,相当于“懒汉模式”的懒加载一样,“非必要不加锁”。
synchronized的时候并不是真的加锁,先进入偏向锁状态,做个标记(非常轻量)。如果整个使用过程中,都没有出现锁竞争,在synchronized执行完之后,取消偏向锁。
但是,如果执行过程中,还有另一个线程也尝试加锁,在他加锁之前,迅速的把偏向锁升级为轻量级锁,另一个线程也就进入阻塞等待状态了。
此时synchronized相当于通过自旋的方式,进行加锁(CAS的自旋一样)。
如果很快其他线程解锁,自旋是非常划算的;如果迟迟拿不到锁,一直自旋,并不划算。
synchroized自旋不是无休止的自旋,自旋到一定程度,就会再次升级为重量级锁(挂起等待锁)。
如果线程进入重量级锁的加锁,并且发生锁竞争,此时线程就会被放到阻塞队列,暂时不参于CPU调度。直到锁被释放,这个线程才有机会被调度到,并且获取到锁。
(2)锁消除
编译器智能的判定,看当前的代码是否要加锁,如果下面场景不需要加锁,程序员也加了,就自动把锁干掉了。
比如:StringBuffer 关键方法都带有synchronized,但如果在单线程环境下,编译器就会把这些多线程干掉。
(3)锁粗化
锁的粒度:synchronized包含的代码越多,粒度就越粗;包含的代码越少,粒度就越细。
通常情况下,认为锁的粒度细一点比较好,加锁的部分代码是不能并发执行的,锁的粒度越细,能并发的代码就越多,反之越少。
但有些情况下,锁的粒度粗一点比较好。
两次加锁解锁之间,间隔非常小,比如直接加一次大锁。每次加锁都是有开销的。
六.JUC的常见类
1.Callable
类似于Runnable接口。
Runnable用来描述一个任务,描述的任务没有返回值。
Callable也用来描述一个任务,描述的任务有返回值。
如果需要使用一个线程单独计算出某个结果,使用Callable比较好一点。
使用Callable时,需要用到一个辅助类FutureTask,它可以帮助我们获取结果。
public class Demo {
public static void main(String[] args) {
//使用Callable计算1+2+3+...+1000
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 0; i < 1000; i++) {
sum += i;
}
return sum;
}
};
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
try {
Integer sum = futureTask.get();
System.out.println(sum);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
}
FutureTask的get方法会阻塞,等到Callable结束。
2.ReentrantLock
标准库提供的另一种锁,顾名思义,时“是可重入的”锁。
synchronized是基于代码块的方式加锁解锁的。
ReentrantLock更传统,使用lock方法和unlock方法来加锁解锁的。
public class Demo {
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock();
reentrantLock.lock();
reentrantLock.unlock();
}
}
中间任意一个条件都需要unlock。中间抛出异常或者return,也会导致unlock出现问题。
解决方法,务必放到finally。
上面这个问题是ReentrantLock的劣势,但也有优势。
(1)ReentrantLock提供了公平锁的构造方式,构造方法参数true使用公平锁,无参数或者false为非公平锁。
(2)sychronized提供的加锁方式就是“四等”,只要获取不到锁,就一直阻塞等待。ReentrantLock提供了更灵活的方式:trylock。方法无参数版本,能加锁就加锁,加不上就放弃。有参数,指定超时时间,加不上锁就等待一会儿,超时时间超过后就放弃。trylock有一个返回值,加锁成功1。
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock();
boolean result = reentrantLock.tryLock();
try{
}finally {
if (result){
reentrantLock.unlock();
}
}
}
(3)ReentrantLock提供了一个更强大、更方便的等待通知机制。
synchronized搭配的是wait、notify,notify唤醒的时候是随机唤醒一个wait的线程。
ReentrantLock搭配的是Condition类,进行唤醒的时候可以唤醒指定的线程。
总结:虽然ReentrantLock有一定优势,但是我们实际开发中还是以synchronzied为主。
3.信号量semaphore
操作系统的信号量和java的信号量是一个东西,java的信号量就是把操作系统原生的信号量封装了一下。
信号量本质上就是一个计数器,描述了“可用资源的个数”。P操作,申请一个可用资源,计数器需要-1;V操作,释放一个可用资源,计数器就要+1(信号量不能为负数)。
P操作如果计数器为0了,继续P操作,就会出现阻塞等待的情况。
P操作使用acquire申请,V操作使用release释放。
考虑一种特殊情况:计数器初始值为1的信号量。针对这个信号量,只有0和1两种取值。执行一次P操作,减1;执行一次V操作,加1。如果进行过一次P操作,再进行P操作就会阻塞等待。
锁可以视为初始值为1的信号量,二元信号量。锁是信号量的一种特殊情况,信号量就是锁的一般表达。
代码中也可以使用semaphore实现类似于锁的效果,来保证线程安全。
4.CountDownLatch
主要提供了两个方法:
(1)await 所有线程被阻塞。
(2)countDown 当所有线程都调用countDown时,await被唤醒。
实际开发中,CountDownLatch有很多使用场景。多线程下载。
七.多线程环境
1.ArrayList在多线程环境下的使用
(1)自己加锁。使用synchronized或者ReentrantLock。
(2)标准库提供了Collections.synchronizedList
使用这个方法把集合类套一层,提供一些ArrayList相关方法,同时是带锁的。
(3)CopyOnWriteArrayList,CopyOnWrite简称COW,也叫做“写实拷贝”。
如果针对这个ArrayList进行读操作,不进行任何额外操作。
如果进行写操作,就拷贝一份新的ArrayList,针对新的进行修改,修改过程中如果有读操作,就继续读旧的数据,当修改完了,就用新的替换旧的(本质就是一个引用之间的赋值,原子的)。
优点,不用加锁。缺点是要求ArrayList比较小。
比如服务器的热加载(reload),这样的功能可以不重启服务器,实现配置的更新。
2.多线程环境使用哈希表
标准库的HashMap是线程不安全的。
HashTable是线程安全的,给关键方法加了synchronized。
更推荐使用ConcurrentHashMap,更优化的线程安全哈希表。
(1)最大的优化,ConcurrentHashMap相比HashTable大大缩小了锁冲突的概率(大锁变小锁)。
HashTable直接在方法上加synchronized,等于在this加锁,只要操作哈希表的任意元素,都会产生锁,也就都可能发生锁冲突。
但是有些元素在进行并发操作的时候,并不会产生线程安全问题,也就不需要使用锁控制。
ConcurrentHashMap的做法是每个链表都有各自的锁,每个链表的头节点作为锁对象(两个线程针对同一个锁对象进行加锁才会有锁竞争、阻塞等待)。
(2)ConcurrentHashMap做了一个激进的操作,针对读操作,不加锁,只针对写操作加锁。
读和读之间,没有冲突;
写和写之间,有冲突;
读和写之间,没有冲突;
写操作在ConcurrentHashMap是原子的,通过volatile+原子的写操作。
(3)ConcurrentHashMap充分使用CAS,通过这个进一步消减加锁操作的数目。比如维护元素个数。
(4)针对扩容,采取“化整为零”的方式。
HashMap/HashTable扩容:
创建一个更大的数组空间,把旧的数组上的链表上的每一个元素搬运到新的数组上(删除+插入),这个扩容操作会在某次put的时候触发,如果元素个数特别多,就会导致搬运操作比较耗时。
ConcurrentHashMap扩容:
创建新的数组,旧的数组保留。每次put操作都往新数组上添加,同时进行一部分搬运(把一小部分旧的元素搬运到新数组上),每次get时,新数组和旧数组都查询,每次remove的时候,只是把元素删了就行。一段时间后,旧数组搬空后,再释放数组。