学前小故事
深入线程
Java并发集合
深入锁机制
Java线程池
cpu与核心
高并发解决方案
学前小故事
1.一切要从CPU说起
2.从CPU到操作系统
3.从单核到多核,如何充分利用多核
4.从进程到线程
5.线程与内存
6.线程的使用
7.从多线程到线程池
8.线程池是如何工作的
9.线程池中线程的数量
一切要从CPU说起
你可能会有疑问,讲多线程为什么要从CPU说起呢?原因很简单,在这里没有那些时髦的概念,你可以更加清晰的看清问题的本质。
CPU并不知道线程、进程之类的概念。
CPU只知道两件事:
1. 从内存中取出指令
2. 执行指令,然后回到1
你看,在这里CPU确实是不知道什么进程、线程之类的概念。
接下来的问题就是CPU从哪里取出指令呢?答案是来自一个被称为Program Counter(简称PC)的寄存器,也就是我们熟知的程序计数器,在这里大家不要把寄存器想的太神秘,你可以简单的把寄存器理解为内存,只不过存取速度更快而已。
PC寄存器中存放的是什么呢?这里存放的是指令在内存中的地址,什么指令呢?是CPU将要执行的下一条指令。
那么是谁来设置PC寄存器中的指令地址呢?
原来PC寄存器中的地址默认是自动加1的,这当然是有道理的,因为大部分情况下CPU都是一条接一条顺序执行,当遇到if、else时,这种顺序执行就被打破了,CPU在执行这类指令时会根据计算结果来动态改变PC寄存器中的值,这样CPU就可以正确的跳转到需要执行的指令了。
聪明的你一定会问,那么PC中的初始值是怎么被设置的呢?
在回答这个问题之前我们需要知道CPU执行的指令来自哪里?是来自内存,废话,内存中的指令是从磁盘中保存的可执行程序加载过来的,磁盘中可执行程序是编译器生成的,编译器又是从哪里生成的机器指令呢?答案就是我们定义的函数。
注意是函数,函数被编译后才会形成CPU执行的指令,那么很自然的,我们该如何让CPU执行一个函数呢?显然我们只需要找到函数被编译后形成的第一条指令就可以了,第一条指令就是函数入口。
现在你应该知道了吧,我们想要CPU执行一个函数,那么只需要把该函数对应的第一条机器指令的地址写入PC寄存器就可以了,这样我们写的函数就开始被CPU执行起来啦。
你可能会有疑问,这和线程有什么关系呢?
从CPU到操作系统
上节中我们明白了CPU的工作原理,我们想让CPU执行某个函数,那么只需要把函数对应的第一条机器执行装入PC寄存器就可以了,这样即使没有操作系统我们也可以让CPU执行程序,虽然可行但这是一个非常繁琐的过程,我们需要:
* 在内存中找到一块大小合适的区域装入程序
* 找到函数入口,设置好PC寄存器让CPU开始执行程序
这两个步骤绝不是那么容易的事情,如果每次在执行程序时程序员自己手动实现上述两个过程会疯掉的,因此聪明的程序员就会想干脆直接写个程序来自动完成上面两个步骤吧。
机器指令需要加载到内存中执行,因此需要记录下内存的起始地址和长度;同时要找到函数的入口地址并写到PC寄存器中,想一想这是不是需要一个数据结构来记录下这些信息:
struct *** {
void* start_addr;
int len;
void* start_point;
...
};
接下来就是起名字时刻。
这个数据结构总要有个名字吧,这个结构体用来记录什么信息呢?记录的是程序在被加载到内存中的运行状态,程序从磁盘加载到内存跑起来叫什么好呢?干脆就叫进程(Process)好了,我们的指导原则就是一定要听上去比较神秘,总之大家都不容易弄懂就对了,我将其称为“弄不懂原则”。
就这样进程诞生了。
CPU执行的第一个函数也起个名字,第一个要被执行的函数听起来比较重要,干脆就叫main函数吧。
完成上述两个步骤的程序也要起个名字,根据“弄不懂原则”这个“简单”的程序就叫操作系统(Operating System)好啦。
就这样操作系统诞生了,程序员要想运行程序再也不用自己手动加载一遍了。
现在进程和操作系统都有了,一切看上去都很完美。
从单核到多核,如何充分利用多核
人类的一大特点就是生命不息折腾不止,从单核折腾到了多核。
这时,假设我们想写一个程序并且要分利用多核该怎么办呢?
有的同学可能会说不是有进程吗,多开几个进程不就可以了?听上去似乎很有道理,但是主要存在这样几个问题:
* 进程是需要占用内存空间的(从上一节能看到这一点),如果多个进程基于同一个可执行程序,那么这些进程其内存区域中的内容几乎完全相同,这显然会造成内存的浪费
计算机处理的任务可能是比较复杂的,这就涉及到了进程间通信,由于各个进程处于不同的内存地址空间,进程间通信天然需要借助操作系统,这就在增大编程难度的同时也增加了系统开销
该怎么办呢?
从进程到线程
让我再来仔细的想一想这个问题,所谓进程无非就是内存中的一段区域,这段区域中保存了CPU执行的机器指令以及函数运行时的堆栈信息,要想让进程运行,就把main函数的第一条机器指令地址写入PC寄存器,这样进程就运行起来了。
进程的缺点在于只有一个入口函数,也就是main函数,因此进程中的机器指令只能被一个CPU执行,那么有没有办法让多个CPU来执行同一个进程中的机器指令呢?
聪明的你应该能想到,既然我们可以把main函数的第一条指令地址写入PC寄存器,那么其它函数和main函数又有什么区别呢?
答案是没什么区别,main函数的特殊之处无非就在于是CPU执行的第一个函数,除此之外再无特别之处,我们可以把PC寄存器指向main函数,就可以把PC寄存器指向任何一个函数。
至此我们解放了思想,一个进程内可以有多个入口函数,也就是说属于同一个进程中的机器指令可以被多个CPU同时执行。
注意,这是一个和进程不同的概念,创建进程时我们需要在内存中找到一块合适的区域以装入进程,然后把CPU的PC寄存器指向main函数,也就是说进程中只有一个执行流。
但是现在不一样了,多个CPU可以在同一个屋檐下(进程占用的内存区域)同时执行属于该进程的多个入口函数,也就是说现在一个进程内可以有多个执行流了。
总是叫执行流好像有点太容易理解了,再次祭出”弄不懂原则“,起个不容易懂的名字,就叫线程吧。
这就是线程的由来。
操作系统为每个进程维护了一堆信息,用来记录进程所处的内存空间等,这堆信息记为数据集A。
同样的,操作系统也需要为线程维护一堆信息,用来记录线程的入口函数或者栈信息等,这堆数据记为数据集B。
显然数据集B要比数据A的量要少,同时不像进程,创建一个线程时无需去内存中找一段内存空间,因为线程是运行在所处进程的地址空间的,这块地址空间在程序启动时已经创建完毕,同时线程是程序在运行期间创建的(进程启动后),因此当线程开始运行的时候这块地址空间就已经存在了,线程可以直接使用。这就是为什么各种教材上提的创建线程要比创建进程快的原因(当然还有其它原因)。
值得注意的是,有了线程这个概念后,我们只需要进程开启后创建多个线程就可以让所有CPU都忙起来,这就是所谓高性能、高并发的根本所在。
很简单,只需要创建出数量合适的线程就可以了。
另外值得注意的一点是,由于各个线程共享进程的内存地址空间,因此线程之间的通信无需借助操作系统,这给程序员带来极大方便的同时也带来了无尽的麻烦,多线程遇到的多数问题都出自于线程间通信简直太方便了以至于非常容易出错。
出错的根源在于CPU执行指令时根本没有线程的概念,多线程编程面临的互斥与同步问题需要程序员自己解决,关于互斥与同步问题限于篇幅就不详细展开了,大部分的操作系统资料都有详细讲解。
最后需要提醒的是,虽然前面关于线程讲解使用的图中用了多个CPU,但不是说一定要有多核才能使用多线程,在单核的情况下一样可以创建出多个线程,原因在于线程是操作系统层面的实现,和有多少个核心是没有关系的,CPU在执行机器指令时也意识不到执行的机器指令属于哪个线程。
即使在只有一个CPU的情况下,操作系统也可以通过线程调度让各个线程“同时”向前推进,方法就是将CPU的时间片在各个线程之间来回分配,这样多个线程看起来就是“同时”运行了,但实际上任意时刻还是只有一个线程在运行。
线程与内存
在前面的讨论中我们知道了线程和CPU的关系,也就是把CPU的PC寄存器指向线程的入口函数,这样线程就可以运行起来了,这就是为什么我们创建线程时必须指定一个入口函数的原因。无论使用任何编程语言,创建一个线程大体相同:
// 设置线程入口函数DoSomething
thread = CreateThread(DoSomething);
// 让线程运行起来
thread.Run();
那么线程和内存又有什么关联呢?
我们知道函数在被执行的时产生的数据包括函数参数、局部变量、返回地址等信息,这些信息是保存在栈中的,线程这个概念还没有出现时进程中只有一个执行流,因此只有一个栈,这个栈的栈底就是进程的入口函数,也就是main函数,假设main函数调用了funA,funcA又调用了funcB,如图所示:
那么有了线程以后了呢?
有了线程以后一个进程中就存在多个执行入口,即同时存在多个执行流,那么只有一个执行流的进程需要一个栈来保存运行时信息,那么很显然有多个执行流时就需要有多个栈来保存各个执行流的信息,也就是说操作系统要为每个线程在进程的地址空间中分配一个栈,即每个线程都有独属于自己的栈,能意识到这一点是极其关键的。
同时我们也可以看到,创建线程是要消耗进程内存空间的,这一点也值得注意。
线程的使用
现在有了线程的概念,那么接下来作为程序员我们该如何使用线程呢?
从生命周期的角度讲,线程要处理的任务有两类:长任务和短任务。
1,长任务,long-lived tasks
顾名思义,就是任务存活的时间很长,比如以我们常用的word为例,我们在word中编辑的文字需要保存在磁盘上,往磁盘上写数据就是一个任务,那么这时一个比较好的方法就是专门创建一个写磁盘的线程,该写线程的生命周期和word进程是一样的,只要打开word就要创建出该写线程,当用户关闭word时该线程才会被销毁,这就是长任务。
这种场景非常适合创建专用的线程来处理某些特定任务,这种情况比较简单。
有长任务,相应的就有短任务。
2,短任务,short-lived tasks
这个概念也很简单,那就是任务的处理时间很短,比如一次网络请求、一次数据库查询等,这种任务可以在短时间内快速处理完成。因此短任务多见于各种Server,像web server、database server、file server、mail server等,这也是互联网行业的同学最常见的场景,这种场景是我们要重点讨论的。
这种场景有两个特点:一个是任务处理所需时间短;另一个是任务数量巨大。
如果让你来处理这种类型的任务该怎么办呢?
你可能会想,这很简单啊,当server接收到一个请求后就创建一个线程来处理任务,处理完成后销毁该线程即可,So easy。
这种方法通常被称为thread-per-request,也就是说来一个请求就创建一个线程:
如果是长任务,那么这种方法可以工作的很好,但是对于大量的短任务这种方法虽然实现简单但是有这样几个缺点:
1. 从前几节我们能看到,线程是操作系统中的概念(这里不讨论用户态线程实现、协程之类),因此创建线程天然需要借助操作系统来完成,操作系统创建和销毁线程是需要消耗时间的
2. 每个线程需要有自己独立的栈,因此当创建大量线程时会消耗过多的内存等系统资源
这就好比你是一个工厂老板(想想都很开心有没有),手里有很多订单,每来一批订单就要招一批工人,生产的产品非常简单,工人们很快就能处理完,处理完这批订单后就把这些千辛万苦招过来的工人辞退掉,当有新的订单时你再千辛万苦的招一遍工人,干活儿5分钟招人10小时,如果你不是励志要让企业倒闭的话大概是不会这么做到的,因此一个更好的策略就是招一批人后就地养着,有订单时处理订单,没有订单时大家可以闲呆着。
这就是线程池的由来。
从多线程到线程池
线程池的概念是非常简单的,无非就是创建一批线程,之后就不再释放了,有任务就提交给这些线程处理,因此无需频繁的创建、销毁线程,同时由于线程池中的线程个数通常是固定的,也不会消耗过多的内存,因此这里的思想就是复用、可控。
线程池是如何工作的
可能有的同学会问,该怎么给线程池提交任务呢?这些任务又是怎么给到线程池中线程呢?
很显然,数据结构中的队列天然适合这种场景,提交任务的就是生产者,消费任务的线程就是消费者,实际上这就是经典的生产者-消费者问题。
现在你应该知道为什么操作系统课程要讲、面试要问这个问题了吧,因为如果你对生产者-消费者问题不理解的话,本质上你是无法正确的写出线程池的。
限于篇幅在这里博主不打算详细的讲解生产者消费者问题,参考操作系统相关资料就能获取答案。这里博主打算讲一讲一般提交给线程池的任务是什么样子的。
一般来说提交给线程池的任务包含两部分:1) 需要被处理的数据;2) 处理数据的函数
struct task {
void* data; // 任务所携带的数据
handler handle; // 处理数据的方法
}
(注意,你也可以把代码中的struct理解成class,也就是对象。)
线程池中的线程会阻塞在队列上,当生产者向队列中写入数据后,线程池中的某个线程会被唤醒,该线程从队列中取出上述结构体(或者对象),以结构体(或者对象)中的数据为参数并调用处理函数:
while(true) {
struct task = GetFromQueue(); // 从队列中取出数据
task->handle(task->data); // 处理数据
}
以上就是线程池最核心的部分。
理解这些你就能明白线程池是如何工作的了。
线程池中线程的数量
现在线程池有了,那么线程池中线程的数量该是多少呢?
在接着往下看前先自己想一想这个问题。
如果你能看到这里说明还没有睡着。
要知道线程池的线程过少就不能充分利用CPU,线程创建的过多反而会造成系统性能下降,内存占用过多,线程切换造成的消耗等等。因此线程的数量既不能太多也不能太少,那到底该是多少呢?
回答这个问题,你需要知道线程池处理的任务有哪几类,有的同学可能会说你不是说有两类吗?长任务和短任务,这个是从生命周期的角度来看的,那么从处理任务所需要的资源角度看也有两种类型,CPU密集型和I/O密集型。
1,CPU密集型
所谓CPU密集型就是说处理任务不需要依赖外部I/O,比如科学计算、矩阵运算等等。在这种情况下只要线程的数量和核数基本相同就可以充分利用CPU资源。
2,I/O密集型
这一类任务可能计算部分所占用时间不多,大部分时间都用在了比如磁盘I/O、网络I/O等。
这种情况下就稍微复杂一些了,你需要利用性能测试工具评估出用在I/O等待上的时间,这里记为WT(wait time),以及CPU计算所需要的时间,这里记为CT(computing time),那么对于一个N核的系统,合适的线程数大概是N * (1 + WT/CT),假设I/O等待时间和计算时间相同,那么你大概需要2N个线程才能充分利用CPU资源,注意这只是一个理论值,具体设置多少需要根据真实的业务场景进行测试。
当然充分利用CPU不是唯一需要考虑的点,随着线程数量的增多,内存占用、系统调度、打开的文件数量、打开的socker数量以及打开的数据库链接等等是都需要考虑的。
因此这里没有万能公式,要具体情况具体分析。
线程池不是万能的
线程池仅仅是多线程的一种使用形式,因此多线程面临的问题线程池同样不能避免,像死锁问题、race condition问题等等,关于这一部分同样可以参考操作系统相关资料就能得到答案,所以基础很重要呀老铁们。
线程池使用的最佳实践
线程池是程序员手中强大的武器,互联网公司的各个server上几乎都能见到线程池的身影,使用线程池前你需要考虑:
* 充分理解你的任务,是长任务还是短任务、是CPU密集型还是I/O密集型,如果两种都有,那么一种可能更好的办法是把这两类任务放到不同的线程池中,这样也许可以更好的确定线程数量
* 如果线程池中的任务有I/O操作,那么务必对此任务设置超时,否则处理该任务的线程可能会一直阻塞下去
* 线程池中的任务最好不要同步等待其它任务的结果
话题总结
我们从CPU开始一路来到常用的线程池,从底层到上层、从硬件到软件。注意,这里通篇没有出现任何特定的编程语言,线程不是语言层面的概念(依然不考虑用户态线程),但是当你真正理解了线程后,相信你可以在任何一门语言下用好多线程,你需要理解的是道,此后才是术。
深入线程
线程的实现方式
线程的生命周期
线程的执行顺序
线程的停止方法
判断线程是否中断
线程的安全问题
Java实现线程安全的方式
线程的内部存储
深入Callable接口
6.Future
7.FutureTask
线程的实现方式
在Java中,实现线程的方式大体上分为三种,通过继承Thread类、实现Runnable接口,实现Callable接口。
继承Thread类
public class ThreadDataPro{
public static void main(String[] args) {
Ticket ticket=new Ticket();
//创建四个线程对象
Thread t1=new Thread(ticket,"窗口1");
Thread t2=new Thread(ticket,"窗口2");
Thread t3=new Thread(ticket,"窗口3");
Thread t4=new Thread(ticket,"窗口4");
t1.start();
t2.start();
t3.start();
t4.start();
}
static class Ticket extends Thread{
private int tickets=10;
//设置线程任务
@Override
public void run() {
//TODO 在此写在线程中执行的业务逻辑
while(true) {
if(tickets>0) {
String name=Thread.currentThread().getName();
//(由于i--不是原子操作(先获取i的值,让后再减一,再把结果赋给i),所以输出的值会有重复的情况,比如4 4 2)
System.out.println(name+"正在发售第"+tickets--+"张票");
}else {
break;
}
}
}
}
}
实现Runnable接口
public class RunnableTest implements Runnable {
@Override public void run() {
//TODO 在此写在线程中执行的业务逻辑
}
}
实现Runnable接口的方法避免了使用Thread单继承的局限性,并且实现了解耦,任务可以被多个线程共享,任务和线程是独立的。
实现Callable接口的代码
public class CallableTest implements Callable<String> {
@Override public String call() throws Exception {
//TODO 在此写在线程中执行的业务逻辑 return null;
}
}
线程的生命周期
一个线程从创建,到最终的消亡,需要经历多种不同的状态,而这些不同的线程状态,由始至终也构成了线程生命周期的不同阶段。线程的生命周期可以总结为下图。
New :初始状态,线程被构建,但是还没有调用start()方法
RUNNABLE :可运行状态,可运行状态可以包括:运行中状态和就绪状态。状态为runnable的线程正在Java虚拟机中执行,但是它可能正在等待来自操作系统的其他资源,例如处理器。
BLOCKED :阻塞状态,处于这个状态的线程需要等待其他线程释放锁或者等待进入synchronized
WAITING :等待状态,处于该状态的线程需要等待其他线程对其进行通知或中断等操作,进而进入下一个状态。由于调用以下方法之一,线程处于等待状态: Object#wait()、Thread#join()、LockSupport#park()
TIMED_WAITING :超时等待状态。可以在一定的时间自行返回。
TERMINATED :终止状态,当前线程执行完毕。
线程的执行顺序
调用Thread的start()方法启动线程时,线程的执行顺序是不确定的。也就是说,在同一个方法中,连续创建多个线程后,调用线程的start()方法的顺序并不能决定线程的执行顺序。这里看一个简单的示例程序,如下所示。
/**
* @author ferao
* @version 1.0.0
* @description 线程的顺序,直接调用Thread.start()方法执行不能确保线程的执行顺序
*/
public class ThreadSort {
public static void main(String[] args){
Thread thread1 = new Thread(() -> {
System.out.println("thread1");
});
Thread thread2 = new Thread(() -> {
System.out.println("thread2");
});
Thread thread3 = new Thread(() -> {
System.out.println("thread3");
});
thread1.start();
thread2.start();
thread3.start();
}
}
在ThreadSort类中分别创建了三个不同的线程,thread1、thread2和thread3,接下来,在程序中
按照顺序分别调用thread1.start()、thread2.start()和thread3.start()方法来分别启动三个不同的线程。
那么,问题来了,线程的执行顺序是否按照thread1、thread2和thread3的顺序执行呢?运行
ThreadSort01的main方法,结果如下所示。
多次运行可以看到,每次运行程序时,线程的执行顺序可能不同。线程的启动顺序并不能决定线程的执行顺序。
在实际业务场景中,有时,后启动的线程可能需要依赖先启动的线程执行完成才能正确的执行线程中的业务逻辑。此时,就需要确保线程的执行顺序。那么如何确保线程的执行顺序呢?
可以使用Thread类中的join()方法来确保线程的执行顺序。例如下面的测试代码
/**
* @author ferao
* @version 1.0.0
* @description 线程的顺序,Thread.join()方法能够确保线程的执行顺序
*/
public class ThreadSort02 {
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
System.out.println("thread1");
});
Thread thread2 = new Thread(() -> {
System.out.println("thread2");
});
Thread thread3 = new Thread(() -> {
System.out.println("thread3");
});
thread1.start();
//实际上让主线程等待子线程执行完成
thread1.join();
thread2.start();
thread2.join();
thread3.start();
thread3.join();
}
}
可以看到,ThreadSort02类比ThreadSort类,在每个线程的启动方法下面添加了调用线程的join()方法。此时,运行ThreadSort02类,结果如下所示。
thread1
thread2
thread3
可以看到,每次运行的结果都是相同的,所以,使用Thread的join()方法能够保证线程的先后执行顺序。
既然Thread类的join()方法能够确保线程的执行顺序,我们就一起来看看Thread类的join()方法到底是个什么鬼。[注意join是一个同步方法]进入Thread的join()方法,如下所示。
public final void join() throws InterruptedException {
join(0);
}
可以看到join()方法调用同类中的一个有参join()方法,并传递参数0。继续跟进代码,如下所示。
public final synchronized void join(long millis)
throws InterruptedException {
long base = System.currentTimeMillis();
long now = 0;
if (millis < 0) {
throw new IllegalArgumentException("timeout value is negative");
}
if (millis == 0) {
while (isAlive()) {
wait(0);
}
} else {
while (isAlive()) {
long delay = millis - now;
if (delay <= 0) {
break;
}
wait(delay);
now = System.currentTimeMillis() - base;
}
}
}
可以看到,有一个long类型参数的join()方法使用了synchroinzed修饰,说明这个方法同一时刻只能被一个实例或者方法调用。由于,传递的参数为0,所以,程序会进入如下代码逻辑。
if (millis == 0) {
while (isAlive()) {
wait(0);
}
}
首先,在代码中以while循环的方式来判断当前线程是否已经启动处于活跃状态,如果已经启动处于活跃状态,则调用同类中的wait()方法,并传递参数0。继续跟进wait()方法,如下所示。
public final native void wait(long timeout) throws InterruptedException;
可以看到,wait()方法是一个本地方法,通过JNI的方式调用JDK底层的方法来使线程等待执行完成。
需要注意的是,调用线程的wait()方法时,会使主线程处于等待状态,等待子线程执行完成后再次向下执行。也就是说,在ThreadSort02类的main()方法中,调用子线程的join()方法,会阻塞main()方法的执行,当子线程执行完成后,main()方法会继续向下执行,启动第二个子线程,并执行子线程的业务逻辑,以此类推。
[拓展]由于需要限制不同的程序之间的访问能力, 防止他们获取别的程序的内存数据, 或者获取外围设备的数据, 并发送到网络, CPU划分出两个权限等级 – 用户态和内核态。
1.用户态:只能受限的访问内存,且不允许访问外围设备,占用cpu的能力被剥夺,cpu资源可以被其他程序获取。
2.内核态:cpu可以访问内存的所有数据,包括外围设备,例如硬盘,网卡,cpu也可以将自己从一个程序切换到另一个程序。
```java
用户态与内核态的切换
所有用户程序都是运行在用户态的, 但是有时候程序确实需要做一些内核态的事情, 例如从硬盘读取数据, 或者从键盘获取输入等.
而唯一可以做这些事情的就是操作系统, 所以此时程序就需要先操作系统请求以程序的名义来执行这些操作.
这时需要一个这样的机制: 用户态程序切换到内核态, 但是不能控制在内核态中执行的指令
这种机制叫系统调用, 在CPU中的实现称之为陷阱指令(Trap Instruction)
```
线程的停止方法
线程自己执行完后自动终止
stop强制终止,不安全
使用interrupt方法
线程对象有一个boolean变量代表是否有中断请求,interrupt方法将线程的中断状态设置会true,但是并没有立刻终止线程,就像告诉你儿子要好好学习一样,但是你儿子怎么样关键看的是你儿子。
public static void main(String[] args) {
try {
MyThread thread = new MyThread();
thread.start();
Thread.sleep(200);
thread.interrupt();
} catch (InterruptedException e) {
System.out.println("main catch");
e.printStackTrace();
}
System.out.println("end!");
}
class MyThread extends Thread {
@Override
public void run() {
super.run();
for (int i = 0; i < 500; i++) {
System.out.println("i=" + (i + 1));
}
}
}
判断线程是否中断
interrupted方法判断当前线程是否中断,清除中断标志
下例thread线程执行了interrupt方法,按道理thread的中断状态应该为true,但是因为interrupted方法判断的是当前线程的中断状态,也就是main线程(main线程执行thread.interrupted方法),所以他的中断状态是false
public class MyThread extends Thread {
@Override
public void run() {
super.run();
for (int i = 0; i < 500000; i++) {
System.out.println("i=" + (i + 1));
}
}
}
public class Run {
public static void main(String[] args) {
try {
MyThread thread = new MyThread();
thread.start();
Thread.sleep(1000);
thread.interrupt();
//Thread.currentThread().interrupt();
System.out.println("是否停止1?="+thread.interrupted());//false
System.out.println("是否停止2?="+thread.interrupted());//false main线程没有被中断!!!
}
下例主线程执行interrupt方法,第一次执行interrupted方法的时候,中断状态为true,但是interrupted方法有清除中断标志的作用,所以再执行的时候输出的是false
public class Run {
public static void main(String[] args) {
try {
Thread.currentThread().interrupt();
System.out.println("是否停止1?="+Thread.interrupted());//true
System.out.println("是否停止2?="+Thread.interrupted());//false
//......
isInterrupted方法判断线程是否中断,不清除中断标志
这里也有判断线程中断的作用,而判断的是他的调用者的中断状态,而且没有清除中断标志的作用,所以两次都是true
public static void main(String[] args) {
MyThread thread=new MyThread();
thread.start();
thread.interrupt();
System.out.println(thread.isInterrupted());//true
System.out.println(thread.isInterrupted());//true
}
线程的安全问题
当一个线程访问该类的某个数据时,采用加锁机制进行保护,其他线程不能进行访问,直到该线程读取完其他线程才可使用,这样不会出现数据不一致或者数据污染。
若不提供数据保护,有可能出现多个线程先后更改数据造成所得到的数据是脏数据。可能出现多个线程先后更改数据造成所得到的数据是脏数据。
解决线程安全问题分为两个方面:控制执行、内存可见
控制执行
控制执行的目的是控制代码执行(顺序)以及是否可以并发执行,这里使用到关键字synchronized.
synchronized它会阻止其他线程获取当前对象的监控锁,这样就使得当前对象中被synchronized关键字保护的代码块无法被其他线程访问,也就无法并发执行。
更重要的是,synchronized还会创建一个内存屏障,内存屏障指令保证了所有cpu操作结果都会直接刷到主内存中,从而保证了操作的内存可见性,同时也使得先获得这个锁的线程的所有操作都happens-before于随后获得这个锁的线程的操作。
内存可见性
内存可见控制的是线程执行结果在内存中对其他线程的可见性。根据Java内存模型的实现,线程在具体执行时,会先拷贝主存数据到线程本地(cpu缓存),操作完成后再把结果从线程本地刷到主存,这里使用到关键字volatile.
volatile会使得所有对volatile变量的读写都会直接刷到主存,即保证了变量的可见性。这样就能满足一些对变量可见性有要求,而对读取顺序没有要求的需求。
使用volatile关键字仅能实现对原始变量(如boolen、 short 、int 、long等)操作的原子性,但需要特别注意,volatile不能保证复合操作的原子性,即使只是i++,实际上也是由多个原子操作组成:read i; inc; write i,假如多个线程同时执行i++,volatile只能保证他们操作的i是同一块内存,但依然可能出现写入脏数据的情况。
在Java 5提供了原子数据类型atomic wrapper classes,对它们的increase之类的操作都是原子操作,
Java实现线程安全的方式
一个程序在运行起来的时候会转换成进程,通常含有多个线程。
通常情况下,一个进程中的比较耗时的操作(如长循环、文件上传下载、网络资源获取等),往往会采用多线程来解决。
比如显示生活中,银行取钱问题、火车票多个售票窗口的问题,通常会涉及到并发的问题,从而需要多线程的技术。
当进程中有多个并发线程进入一个重要数据的代码块时,在修改数据的过程中,很有可能引发线程安全问题,从而造成数据异常。例如,正常逻辑下,同一个编号的火车票只能售出一次,却由于线程安全问题而被多次售出,从而引起实际业务异常。
现在我们就以售票问题来演示线程安全的问题
在不对多线程数据进行保护的情况下会引发的状况
public class ThreadUnSecurity {
static int tickets = 10;
class SellTickets implements Runnable{
@Override
public void run() {
// 未加同步时产生脏数据
while(tickets > 0) {
System.out.println(Thread.currentThread().getName()+"--->售出第: "+tickets+" 票");
tickets--;
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
if (tickets <= 0) {
System.out.println(Thread.currentThread().getName()+"--->售票结束!");
}
}
}
public static void main(String[] args) {
SellTickets sell = new ThreadUnSecurity().new SellTickets();
Thread thread1 = new Thread(sell, "1号窗口");
Thread thread2 = new Thread(sell, "2号窗口");
Thread thread3 = new Thread(sell, "3号窗口");
Thread thread4 = new Thread(sell, "4号窗口");
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
}
上述代码运行的结果:
我们可以看出同一张票在不对票数进行保护时会出现同一张票会被出售多次!由于线程调度中的不确定性,读者在演示上述代码时,出现的运行结果会有不同。
第一种方式:同步代码块
package com.bpan.spring.beans.thread;
import com.sun.org.apache.regexp.internal.recompile;
public class ThreadSynchronizedSecurity {
static int tickets = 10;
class SellTickets implements Runnable{
@Override
public void run() {
// 同步代码块
while(tickets > 0) {
synchronized (this) {
// System.out.println(this.getClass().getName().toString());
if (tickets <= 0) {
return;
}
System.out.println(Thread.currentThread().getName()+"--->售出第: "+tickets+" 票");
tickets--;
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
if (tickets <= 0) {
System.out.println(Thread.currentThread().getName()+"--->售票结束!");
}
}
}
}
public static void main(String[] args) {
SellTickets sell = new ThreadSynchronizedSecurity().new SellTickets();
Thread thread1 = new Thread(sell, "1号窗口");
Thread thread2 = new Thread(sell, "2号窗口");
Thread thread3 = new Thread(sell, "3号窗口");
Thread thread4 = new Thread(sell, "4号窗口");
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
}
输出结果读者可自行调试,不会出现同一张票被出售多次的情况。
第二种方式:同步方法
package com.bpan.spring.beans.thread;
public class ThreadSynchroniazedMethodSecurity {
static int tickets = 10;
class SellTickets implements Runnable{
@Override
public void run() {
//同步方法
while (tickets > 0) {
synMethod();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
if (tickets<=0) {
System.out.println(Thread.currentThread().getName()+"--->售票结束");
}
}
}
synchronized void synMethod() {
synchronized (this) {
if (tickets <=0) {
return;
}
System.out.println(Thread.currentThread().getName()+"---->售出第 "+tickets+" 票 ");
tickets-- ;
}
}
}
public static void main(String[] args) {
SellTickets sell = new ThreadSynchroniazedMethodSecurity().new SellTickets();
Thread thread1 = new Thread(sell, "1号窗口");
Thread thread2 = new Thread(sell, "2号窗口");
Thread thread3 = new Thread(sell, "3号窗口");
Thread thread4 = new Thread(sell, "4号窗口");
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
}
读者可自行调试上述代码的运行结果
第三种方式:Lock锁机制
通过创建Lock对象,采用lock()加锁,unlock()解锁,来保护指定的代码块
package com.bpan.spring.beans.thread;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ThreadLockSecurity {
static int tickets = 10;
class SellTickets implements Runnable{
Lock lock = new ReentrantLock();
@Override
public void run() {
// Lock锁机制
while(tickets > 0) {
try {
lock.lock();
if (tickets <= 0) {
return;
}
System.out.println(Thread.currentThread().getName()+"--->售出第: "+tickets+" 票");
tickets--;
} catch (Exception e1) {
// TODO Auto-generated catch block
e1.printStackTrace();
}finally {
lock.unlock();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
if (tickets <= 0) {
System.out.println(Thread.currentThread().getName()+"--->售票结束!");
}
}
}
public static void main(String[] args) {
SellTickets sell = new ThreadLockSecurity().new SellTickets();
Thread thread1 = new Thread(sell, "1号窗口");
Thread thread2 = new Thread(sell, "2号窗口");
Thread thread3 = new Thread(sell, "3号窗口");
Thread thread4 = new Thread(sell, "4号窗口");
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
}
由于synchronized是在JVM层面实现的,因此系统可以监控锁的释放与否;而ReentrantLock是使用代码实现的,系统无法自动释放锁,需要在代码中的finally子句中显式释放锁lock.unlock()。
另外,在并发量比较小的情况下,使用synchronized是个不错的选择;但是在并发量比较高的情况下,其性能下降会很严重,此时ReentrantLock是个不错的方案。
线程的内部存储
java.lang.ThreadLocal
是一个线程内部的存储类,可以在指定线程内存储数据,数据存储以后只有指定线程可以得到存储数据。
官方解释如下:
/ * This class provides thread-local variables. These variables differ from * their normal counterparts in that each thread that accesses one (via its * {@code get} or {@code set} method) has its own, independently initialized * copy of the variable. {@code ThreadLocal} instances are typically private * static fields in classes that wish to associate state with a thread (e.g., * a user ID or Transaction ID). */大致意思就是ThreadLocal提供了线程内存储变量的能力,这些变量不同之处在于每个线程读取的变量是对应的、互相独立的。要得到当前线程对应的值可以通过get和set方法获取
set源码方法
public void set(T value) {
//获取当前线程
Thread t = Thread.currentThread();
//实际存储的数据结构类型
ThreadLocalMap map = getMap(t);
//如果存在map就直接set,没有则创建map并set
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
//getMap方法
ThreadLocalMap getMap(Thread t) {
//thred中维护了一个ThreadLocalMap
return t.threadLocals;
}
//createMap
void createMap(Thread t, T firstValue) {
//实例化一个新的ThreadLocalMap,并赋值给线程的成员变量threadLocals
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
从上面代码可以看出每个线程持有一个ThreadLocalMap对象。每一个新的线程Thread都会实例化一个ThreadLocalMap并赋值给成员变量threadLocals,使用时若已经存在threadLocals则直接使用已经存在的对象
get源码方法
//ThreadLocal中get方法
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
//ThreadLocalMap中getEntry方法
private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
if (e != null && e.get() == key)
return e;
else
return getEntryAfterMiss(key, i, e);
}
get方法是通过计算出索引直接从数组对应位置读取即可。
ThreadLocal和Synchronized都是为了解决多线程中相同变量的访问冲突问题,不同的点是Synchronized是通过线程等待,牺牲时间来解决访问冲突;
ThreadLocal是通过每个线程单独一份存储空间,牺牲空间来解决冲突,并且相比于Synchronized,ThreadLocal具有线程隔离的效果,只有在线程内才能获取到对应的值,线程外则不能访问到想要的值。正因为ThreadLocal的线程隔离特性,使他的应用场景相对来说更为特殊一些。在android中Looper、ActivityThread以及AMS中都用到了ThreadLocal。当某些数据是以线程为作用域并且不同线程具有不同的数据副本的时候,就可以考虑采用ThreadLocal。
static ThreadLocal<String> localVar = new ThreadLocal<>();
static void print(String str) {
//打印当前线程中本地内存中本地变量的值
System.out.println(str + " :" + localVar.get());
//清除本地内存中的本地变量
localVar.remove();
}
public static void main(String[] args) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
//设置线程1中本地变量的值
localVar.set("localVar1");
//调用打印方法
//print("thread1");
//打印本地变量
System.out.println("after remove : " + localVar.get());
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
//设置线程1中本地变量的值
localVar.set("localVar2");
//调用打印方法
//print("thread2");
//打印本地变量
System.out.println("after remove : " + localVar.get());
}
});
t1.start();
t2.start();
}
深入Callable接口
在Java的多线程编程中,除了Thread类和Runnable接口外,不得不说的就是Callable接口、Future接口了。使用继承Thread类或者实现Runnable接口的线程,无法返回最终的执行结果数据,只能等待线程执行完成。此时,如果想要获取线程执行后的返回结果,那么,Callable和Future就派上用场了。
Callable接口位于java.util.concurre包下,是JDK1.5新增的泛型接口,里面只声明了一个call方法,call方法带有返回值,返回类型就是传递进来的V类型,并且可以抛出异常。在JDK1.8中,被声明为函数式接口。Callable接口可以看作是Runnable接口的补充,通过它可以在任务执行完毕之后得到任务执行结果。
[拓展]函数式接口:在JDK 1.8中只声明有一个方法的接口为函数式接口,函数式接口可以使用@FunctionalInterface注解修饰,也可以不使用@FunctionalInterface注解修饰。只要一个接口中只包含有一个方法,那么,这个接口就是函数式接口。
Callable接口源码分析
@FunctionalInterface
public interface Callable<V> {
/**
* Computes a result, or throws an exception if unable to do so.
*
* @return computed result
* @throws Exception if unable to compute a result
*/
V call() throws Exception;
}
在实现Callable接口的子类中,有几个比较重要的类,如下图所示。
分别是:Executors类中的静态内部类:PrivilegedCallable、PrivilegedCallableUsingCurrentClassLoader、RunnableAdapter和Task类下的TaskCallable。
Callable接口的实现类分析
接下来,分析的类主要有:PrivilegedCallable、PrivilegedCallableUsingCurrentClassLoader、RunnableAdapter和Task类下的TaskCallable。
虽然这些类在实际工作中很少被直接用到,但是作为一名合格的开发工程师,设置是秃顶的资深专家来说,了解并掌握这些类的实现有助你进一步理解Callable接口,并提高专业技能
PrivilegedCallable
PrivilegedCallable类是Callable接口的一个特殊实现类,它表明Callable对象有某种特权来访问系统的某种资源,PrivilegedCallable类的源代码如下所示。
/**
* A callable that runs under established access control settings
*/
static final class PrivilegedCallable<T> implements Callable<T> {
private final Callable<T> task;
private final AccessControlContext acc;
PrivilegedCallable(Callable<T> task) {
this.task = task;
this.acc = AccessController.getContext();
}
public T call() throws Exception {
try {
return AccessController.doPrivileged(
new PrivilegedExceptionAction<T>() {
public T run() throws Exception {
return task.call();
}
}, acc);
} catch (PrivilegedActionException e) {
throw e.getException();
}
}
}
从PrivilegedCallable类的源代码来看,可以将PrivilegedCallable看成是对Callable接口的封装,并且这个类也继承了Callable接口。
在PrivilegedCallable类中有两个成员变量,分别是Callable接口的实例对象和AccessControlContext类的实例对象,如下所示。
private final Callable<T> task;
private final AccessControlContext acc;
其中,AccessControlContext类可以理解为一个具有系统资源访问决策的上下文类,通过这个类可以访问系统的特定资源。通过类的构造方法可以看出,在实例化AccessControlContext类的对象时,只需要传递Callable接口子类的对象即可,如下所示。
PrivilegedCallable(Callable<T> task) {
this.task = task;
this.acc = AccessController.getContext();
}
AccessControlContext类的对象是通过AccessController类的getContext()方法获取的,这里,查看
AccessController类的getContext()方法,如下所示。
public static AccessControlContext getContext()
{
AccessControlContext acc = getStackAccessControlContext();
if (acc == null) {
// all we had was privileged system code. We don't want
// to return null though, so we construct a real ACC.
return new AccessControlContext(null, true);
} else {
return acc.optimize();
}
}
通过AccessController的getContext()方法可以看出,首先通过getStackAccessControlContext()方法来获取AccessControlContext对象实例。如果获取的AccessControlContext对象实例为空,则通过调用AccessControlContext类的构造方法实例化,否则,调用AccessControlContext对象实例的optimize()方法返回AccessControlContext对象实例。
这里,我们先看下getStackAccessControlContext()方法是个什么鬼。
private static native AccessControlContext getStackAccessControlContext();
原来是个本地方法,方法的字面意思就是获取能够访问系统栈的决策上下文对象。
接下来,我们回到PrivilegedCallable类的call()方法,如下所示。
public T call() throws Exception {
try {
return AccessController.doPrivileged(
new PrivilegedExceptionAction<T>() {
public T run() throws Exception {
return task.call();
}
}, acc);
} catch (PrivilegedActionException e) {
throw e.getException();
}
}
Future
通过Future接口main方法中的任务会异步执行,main方法不用等待一个任务的执行完成,只需要往下执行就行。一个任务的执行结果又该怎么获取呢?这里就需要用到Future接口中的isDone()方法来判断任务是否执行完,如果执行完成则可获取结果,如果没有完成则需要等待。
[拓展问题]
可见虽然主线程中的多个任务是异步执行,但是无法确定任务什么时候执行完成,只能通过不断去监听以获取结果,所以这里是阻塞的。这样,可能某一个任务执行时间很长会拖累整个主任务的执行。
Future接口介绍
Future接口位于java.util.concurrent包下,是JDK1.5新增接口。该接口提供了三种功能:
1)判断任务是否完成;
2)能够中断任务;
3)能够获取任务执行结果
对于具体的Runnable或者Callable任务的执行结果可以进行 查询是否完成、取消、获取结果。
因其实接口无法直接用来创建对象使用的,因此就有了FutureTask(下面会介绍);
Future接口源码分析
Future接口为泛型接口,里面声明了五个方法。使用时通过此五个方法去控制f()方法的计算过程。
public interface Future<V> {
boolean cancel(boolean mayInterruptIfRunning);
boolean isCancelled();
boolean isDone();
V get() throws InterruptedException, ExecutionException;
V get(long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException;
}
方法 | 含义 |
---|---|
boolean cancel(boolean mayInterruptIfRunning) |
用来取消任务,如果取消任务成功则返回true,如果取消任务失败则返回false。参数mayInterruptIfRunning表示是否允许取消正在执行却没有执行完毕的任务,如果设置true,则表示可以取消正在执行过程中的任务。如果任务已经完成,则无论mayInterruptIfRunning为true还是false,此方法肯定返回false,即如果取消已经完成的任务会返回false;如果任务正在执行,若mayInterruptIfRunning设置为true,则返回true,若mayInterruptIfRunning设置为false,则返回false;如果任务还没有执行,则无论mayInterruptIfRunning为true还是false,肯定返回true。 |
boolean isCancelled() |
表示任务是否被取消成功,如果在任务正常完成前被取消成功,则返回 true |
boolean isDone() |
表示任务是否已经完成,若任务完成,则返回true |
V get() |
用来获取执行结果,这个方法会产生阻塞,会一直等到任务执行完毕才返回 |
V get(long timeout, TimeUnit unit) |
用来获取执行结果,这个方法会产生阻塞,如果在指定时间内,还没获取到结果,就直接返回null |
Future接口核心思想
存在一个计算过程可能非常耗时的方法f()。我们不需要等待f()返回,只要在调用f()的时候,立马返回一个Future,通过Future这个数据结构去控制方法f()的计算过程。
Future接口的使用案例
假如你突然想做饭,但是没有厨具,也没有食材,厨具决定网上购买因为比较方便,食材决定去超市买因为更放心。
那么在快递员送厨具的期间,我们肯定不会闲着,可以去超市买食材。所以,在主线程里面另起一个子线程去网购厨具。
public class ThreadT3 {
public static void main(String[] args) throws InterruptedException {
long startTime = System.currentTimeMillis();
// 第一步 网购厨具
OnlineShopping thread = new OnlineShopping();
thread.start();
thread.join(); // 保证厨具送到
// 第二步 去超市购买食材
Thread.sleep(2000); // 模拟购买食材时间
Shicai shicai = new Shicai();
System.out.println("第二步:食材到位");
// 第三步 用厨具烹饪食材
System.out.println("第三步:开始展现厨艺");
cook(thread.chuju, shicai);
System.out.println("总共用时" + (System.currentTimeMillis() - startTime) + "ms");
}
// 网购厨具线程
static class OnlineShopping extends Thread {
private Chuju chuju;
@Override
public void run() {
System.out.println("第一步:下单");
System.out.println("第一步:等待送货");
try {
Thread.sleep(5000); // 模拟送货时间
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("第一步:快递送到");
chuju = new Chuju();
}
}
// 用厨具烹饪食材
static void cook(Chuju chuju, Shicai shicai) {}
// 厨具类
static class Chuju {}
// 食材类
static class Shicai {}
}
[执行结果]
第一步:下单
第一步:等待送货
第一步:快递送到
第二步:食材到位
第三步:开始展现厨艺
总共用时7013ms
子线程执行的结果是要返回厨具的,而run()方法是没有返回值。此时多线程已经失去了意义。在厨具送到期间,我们不能干任何事 [对应代码为调用join方法阻塞主线程]。
换句话说,Java现在的多线程机制,核心方法run是没有返回值的;如果要保存run方法里面的计算结果,必须等待run方法计算完,无论计算过程多么耗时。
此类问题解决方案的核心就是Future模式。在子线程run()方法计算的期间,在主线程里面继续异步执行。
public class ThreadT4 {
public static void main(String[] args) throws InterruptedException, ExecutionException {
long startTime = System.currentTimeMillis();
// 第一步 网购厨具
Callable<Chuju> onlineShopping = new Callable<Chuju>() {
@Override
public Chuju call() throws Exception {
System.out.println("第一步:下单");
System.out.println("第一步:等待送货");
Thread.sleep(5000); // 模拟送货时间
System.out.println("第一步:快递送到");
return new Chuju();
}
};
FutureTask<Chuju> task = new FutureTask<Chuju>(onlineShopping);
new Thread(task).start();
// 第二步 去超市购买食材
Thread.sleep(2000); // 模拟购买食材时间
Shicai shicai = new Shicai();
System.out.println("第二步:食材到位");
// 第三步 用厨具烹饪食材
if (!task.isDone()) { // 联系快递员,询问是否到货
System.out.println("第三步:厨具还没到,心情好就等着(心情不好就调用cancel方法取消订单)");
}
Chuju chuju = task.get();
System.out.println("第三步:厨具到位,开始展现厨艺");
cook(chuju, shicai);
System.out.println("总共用时" + (System.currentTimeMillis() - startTime) + "ms");
}
// 用厨具烹饪食材
static void cook(Chuju chuju, Shicai shicai) {}
// 厨具类
static class Chuju {}
// 食材类
static class Shicai {}
}
[执行结果]
第一步:下单
第一步:等待送货
第二步:食材到位
第三步:厨具还没到,心情好就等着(心情不好就调用cancel方法取消订单)
第一步:快递送到
第三步:厨具到位,开始展现厨艺
总共用时5003ms
可以看见,在快递员送厨具的期间,我们没有闲着,可以去买食材;而且我们知道厨具到没到,甚至可以在厨具没到的时候,取消订单不要了。
技术上,把Callable实例当作参数,生成一个FutureTask的对象,然后把这个对象当作一个Runnable,作为参数另起线程。再观察FutureTask继承体系:
public interface RunnableFuture
public class FutureTask
这个继承体系的核心接口是Future;
Future接口的拓展问题案例
/**
* @author whb
*/
@Slf4j
@RestController
@RequestMapping(value = "/api/guava")
public class GuavaController {
public static final ExecutorService service = Executors.newCachedThreadPool();
public static void main(String[] args) throws Exception {
long start = System.currentTimeMillis();
// 任务1
Future<Boolean> booleanTask = service.submit(new Callable<Boolean>() {
@Override
public Boolean call() throws Exception {
Thread.sleep(10000);
return true;
}
});
// 任务2
Future<String> stringTask = service.submit(new Callable<String>() {
@Override
public String call() throws Exception {
Thread.sleep(3000);
return "Hello World";
}
});
// 任务3
Future<Integer> integerTask = service.submit(new Callable<Integer>() {
@Override
public Integer call() throws Exception {
Thread.sleep(2000);
return new Random().nextInt(100);
}
});
while (true) {
if (booleanTask.isDone() && !booleanTask.isCancelled()) {
Boolean result = booleanTask.get();
System.err.println("任务1-10s: " + result);
break;
}
}
while (true) {
if (stringTask.isDone() && !stringTask.isCancelled()) {
String result = stringTask.get();
System.err.println("任务2-3s: " + result);
break;
}
}
while (true) {
if (integerTask.isDone() && !integerTask.isCancelled()) {
Integer result = integerTask.get();
System.err.println("任务3-2s:" + result);
break;
}
}
// 执行时间
System.err.println("time: " + (System.currentTimeMillis() - start));
}
}
[执行结果]
任务1-10s: true
任务2-3s: Hello World
任务3-2s:94
time: 10015
因为我们一开始用 Thread1.get() 获取第一个线程的结果时,是阻塞的,而且我们假定任务1执行了10s钟,导致了线程2(3s就执行完任务)和线程3(2s就执行完任务)都执行完了任务,也不打印出来。那在实际业务中,这种方法肯定是不可取的。
Future接口的拓展问题解决方案
引入Guava Future。其能够 减少主函数的等待时间,使得多任务能够异步非阻塞执行。
ListenableFuture是可以监听的Future,它是对java原生Future的扩展增强。
Future表示一个异步计算任务,当任务完成时可以得到计算结果。如果希望计算完成时马上就拿到结果展示给用户或者做另外的计算,就必须使用另一个线程不断的查询计算状态。这样做会使得代码复杂,且效率低下。
如果使用ListenableFuture,Guava会帮助检测Future是否完成了,如果完成就自动调用回调函数,这样可以减少并发程序的复杂度。
/**
* @author whb
*/
@Slf4j
@RestController
@RequestMapping(value = "/api/guava")
public class GuavaController {
public static final ListeningExecutorService service = MoreExecutors.listeningDecorator(Executors.newCachedThreadPool());
public static void main(String[] args) {
long start = System.currentTimeMillis();
// 任务1
ListenableFuture<Boolean> booleanTask = service.submit(new Callable<Boolean>() {
@Override
public Boolean call() throws Exception {
Thread.sleep(10000);
return true;
}
});
Futures.addCallback(booleanTask, new FutureCallback<Boolean>() {
@Override
public void onSuccess(Boolean result) {
System.out.println("BooleanTask.任务1-10s: " + result);
}
@Override
public void onFailure(Throwable throwable) {
System.out.println("BooleanTask.throwable: " + throwable);
}
});
// 任务2
ListenableFuture<String> stringTask = service.submit(new Callable<String>() {
@Override
public String call() throws Exception {
Thread.sleep(3000);
return "Hello World";
}
});
Futures.addCallback(stringTask, new FutureCallback<String>() {
@Override
public void onSuccess(String result) {
System.out.println("StringTask.任务2-3s: " + result);
}
@Override
public void onFailure(Throwable t) {
}
});
// 任务3
ListenableFuture<Integer> integerTask = service.submit(new Callable<Integer>() {
@Override
public Integer call() throws Exception {
Thread.sleep(2000);
return new Random().nextInt(100);
}
});
Futures.addCallback(integerTask, new FutureCallback<Integer>() {
@Override
public void onSuccess(Integer result) {
System.err.println("IntegerTask.任务3-2s:: " + result);
}
@Override
public void onFailure(Throwable t) {
}
});
// 执行时间
System.err.println("time: " + (System.currentTimeMillis() - start));
}
}
[执行结果]
time: 16
IntegerTask.任务3-2s:: 95
StringTask.任务2-3s: Hello World
BooleanTask.任务1-10s: true
这里我们可以看到,每一个任务下面,都去获取任务的结果,从代码来看,任务1执行时间是10s,任务2是3s,任务3是2s,虽然代码里是任务1先执行的,但是从打印结果来看,是执行时间最少的先打印,执行时间最长的是最后打印,说明它获取结果时,只要结果有反馈,就能获取到,因为它是非阻塞的。
FutureTask
Future接口源码分析
FutureTask类实现了RunnableFuture接口,RunnableFuture继承了Runnable接口和Future接口,而FutureTask实现了RunnableFuture接口。所以它既可以作为Runnable被线程执行,又可以作为Future得到Callable的返回值。
public class FutureTask<V> implements RunnableFuture<V>{
...
public FutureTask(Callable<V> callable) {
}
public FutureTask(Runnable runnable, V result) {
}
...
}
public interface RunnableFuture<V> extends Runnable, Future<V> {
void run();
}
cpu与核心
cpu与核心
物理核
物理核数量=cpu数(机子上装的cpu的数量)*每个cpu的核心数
虚拟核
● 所谓的4核8线程,4核指的是物理核心。通过超线程技术,用一个物理核模拟两个虚拟核,每个核两个线程,总数为8线程。
● 在操作系统看来是8个核,但是实际上是4个物理核。
● 通过超线程技术可以实现单个物理核实现线程级别的并行计算,但是比不上性能两个物理核。
单核cpu和多核cpu
● 都是一个cpu,不同的是每个cpu上的核心数
● 多核cpu是多个单核cpu的替代方案,多核cpu减小了体积,同时也减少了功耗
● 一个核心只能同时执行一个线程
进程和线程
进程的状态
进程有着「运行 - 暂停 - 运行」的活动规律。一般说来,一个进程并不是自始至终连续不停地运行的,一般说来,
一个进程并不是自始至终连续不停地运行的,它与并发执行中的其他进程的执行是相互制约的。
它有时处于运行状态,有时又由于某种原因而暂停运行处于等待状态,当使它暂停的原因消失后,它又进入准备运行状态。
所以,在一个进程的活动期间至少具备三种基本状态,即运行状态、就绪状态、阻塞状态。
下图中各个状态的意义:
运行状态(Runing):该时刻进程占用 CPU;
就绪状态(Ready):可运行,但因为其他进程正在运行而暂停停止;
阻塞状态(Blocked):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,
即使给它CPU控制权,它也无法运行;
(
当然,进程另外两个基本状态
创建状态(new):进程正在被创建时的状态;
结束状态(Exit):进程正在从系统中消失时的状态;
于是,一个完整的进程状态的变迁如下下图
)
对于线程和进程,我们可以这么理解:
当进程只有一个线程时,可以认为进程就等于线程;
当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源,这些资源在上下文切换时是不需要修改的;
另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。
线程上下文切换的是什么?
这还得看线程是不是属于同一个进程:
● 当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样;
● 当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,
只需要切换线程的私有数据、寄存器等不共享的数据;
所以,线程的上下文切换相比进程,开销要小很多。
线程
线程状态
含义
在正式学习Thread类中的具体方法之前,先来了解一下线程有哪些状态,这个将会有助于后面对Thread类中的方法的理解。
线程从创建到最终的消亡,要经历若干个状态。根据Thread的内部类State中可得,线程可以处于以下状态之一:
NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。
在给定的时间点,线程只能处于一种状态,这些状态是虚拟机状态,不反映任何操作系统线程状态。
状态位置
java.lang.Thread&State
状态流转
1.当需要新起一个线程来执行某个子任务时,就创建了一个线程。但是线程创建之后,不会立即进入就绪状态,
因为线程的运行需要一些条件(比如内存资源,譬如程序计数器、Java栈、本地方法栈都是线程私有的,
所以需要为线程分配一定的内存空间),只有线程运行需要的所有条件满足了,才进入就绪状态。
2.当线程进入就绪状态后,不代表立刻就能获取CPU执行时间,也许此时CPU正在执行其他的事情,
因此它要等待。当得到CPU执行时间之后,线程便真正进入运行状态。
3.线程在运行状态过程中,可能有多个原因导致当前线程不继续运行下去,比如
用户主动让线程睡眠(睡眠一定的时间之后再重新执行)、
用户主动让线程等待,
或者被同步块给阻塞,
此时就对应着多个状态:time waiting(睡眠或等待一定的事件)、waiting(等待被唤醒)、blocked(阻塞)。
4.当由于突然中断或者子任务执行完毕,线程就会被消亡。
(下面这副图描述了线程从创建到消亡之间的状态:)
在有些教程上将blocked、waiting、time waiting统称为阻塞状态,这个也是可以的,只不过这里我想将线程的状态和Java中的方法调用联系起来,所以将waiting和time waiting两个状态分离出来。
线程生命周期 4种状态
New(新建):线程对象在编程语言级别创建成功,但在操作系统中还并没有创建对应的线程,这个时候的线程还不能获得CPU资源
RUNNABLE(运行):java RUNNABLE状态下的线程细分为两种情况,一种是处于可运行状态的线程,
处于可运行状态的线程可以获得CPU资源,另一种是处于运行中的线程,
这种状态的下线程已经获得了CPU的资源正在运行,JAVA并没有对可运行状态和运行状态进行显示的区分,
但是这两种状态的线程对于操作系统来说是不同的,所以我们还是需要有概念上的区分。
WAITING、TIME_WAITING 、BLOCKED(休眠状态):
当一个处于RUNNABLE状态中的线程调用了阻塞API方法时,线程进入休眠状态,
休眠状态下的线程无法获得CPU资源,只有当某个条件点到达时候唤醒该线程,
线程变成可运行状态才可以重新获得CPU资源;我们通常把JAVA 中的BLOCKED、WAITING、TIMED_WAITING
三种的阻状态统一归为休眠状态。
BLOCKED: 当一个线程在等待synchronizd同步块的锁时,线程会处于BLOCKED状态。
WAITING: 当一个处于RUNNABLE的线程调用该线程的阻塞API时线程进入WAITING状态,直到其他线程将其
唤醒。
TIME_WAITING: TIME_WAITING 状态和WAITING状态区别在于TIME_WAITING在对应的方法加了超时时间。
TERMINATED(已终止):线程执行结束或者执行异常后线程终止。
线程常用方法
public synchronized void start()
start()用来启动一个线程,当调用start方法后,系统才会开启一个新的线程来执行用户定义的子任务,
在这个过程中,会为相应的线程分配需要的资源。
public void run()
run()方法是不需要用户来调用的,当通过start方法启动一个线程之后,当线程获得了CPU执行时间,
便进入run方法体去执行具体的任务。注意,继承Thread类必须重写run方法,在run方法中定义具体要执行的任务。
public static native void sleep(long millis)
public static void sleep(long millis, int nanos)
(sleep方法有两个重载版本)sleep相当于让线程睡眠,交出CPU,让CPU去执行其他的任务。
如果需要让当前正在执行的线程暂停一段时间,并进入阻塞状态,则可以通过调用Thread类的静态sleep()方法来实现。
当前线程调用sleep()方法进入阻塞状态后,在其睡眠时间内,该线程不会获得执行机会,
即使系统中没有其他可执行线程,处于sleep()中的线程也不会执行,因此sleep()方法常用来暂停程序的执行
但是有一点要非常注意,sleep方法不会释放锁,也就是说如果当前线程持有对某个对象的锁,则即使调用sleep方法,
其他线程也无法访问这个对象
示例
public class SleepT0 {
private int i = 0;
private Object object = new Object();
public static void main(String[] args) throws IOException {
SleepT0 test = new SleepT0();
MyThread thread1 = test.new MyThread();
MyThread thread2 = test.new MyThread();
thread1.start();
thread2.start();
}
class MyThread extends Thread{
@Override
public void run() {
synchronized (object) {
i++;
System.out.println("number : "+i);
try {
System.out.println("线程"+Thread.currentThread().getName()+"进入睡眠状态");
Thread.currentThread().sleep(10000);
} catch(InterruptedException e) {
// TODO: handle exception
}
System.out.println("线程"+Thread.currentThread().getName()+"睡眠结束");
i++;
System.out.println("synchronized end , number :"+i);
}
}
}
}
number : 1
线程Thread-0进入睡眠状态
线程Thread-0睡眠结束
synchronized end , number :2
number : 3
线程Thread-1进入睡眠状态
线程Thread-1睡眠结束
synchronized end , number :4
从上面输出结果可以看出,当Thread-0进入睡眠状态之后,Thread-1并没有去执行具体的任务。
只有当Thread-0执行完之后,此时Thread-0释放了对象锁,Thread-1才开始执行。
注意,如果调用了sleep方法,必须捕获InterruptedException异常或者将该异常向上层抛出。当线程睡眠时间满后,
不一定会立即得到执行,因为此时可能CPU正在执行其他的任务。所以说调用sleep方法相当于让线程进入阻塞状态。
public static native void yield();
yield()方法和sleep()方法有点相似,它也是Thread类提供的一个静态方法,
它也可以让当前正在执行的线程暂停,但它不会阻塞该线程,它只是将该线程转入到就绪状态。
即让当前线程暂停一下,让系统的线程调度器重新调度一次,完全可能的情况是:当某个线程调用了yield()方法暂停之后,
线程调度器又将其调度出来重新执行。
调用yield方法会让当前线程交出CPU权限,让CPU去执行其他的线程。它跟sleep方法类似,同样不会释放锁。
但是yield不能控制具体的交出CPU的时间,另外, 当某个线程调用了yield()方法之后,只有优先级与当前线程相同或者
比当前线程更高的处于就绪状态的线程才会获得执行机会。
注意,调用yield方法并不会让线程进入阻塞状态,而是让线程重回就绪状态,它只需要等待重新获取CPU执行时间,
这一点是和sleep方法不一样的。
public final void join()
public final synchronized void join(long millis) //参数毫秒
public final synchronized void join(long millis, int nanos) //第一参数为毫秒,第二个参数为纳秒
假如在main线程中,调用thread.join方法,则main方法会等待thread线程执行完毕或者等待一定的时间。
如果调用的是无参join方法,则等待thread执行完毕,如果调用的是指定了时间参数的join方法,则等待一定的事件。
示例
public class JoinT0 {
public static void main(String[] args) throws IOException {
System.out.println("进入线程" + Thread.currentThread().getName());
JoinT0 test = new JoinT0();
MyThread thread1 = test.new MyThread();
thread1.start();
try {
System.out.println("线程" + Thread.currentThread().getName() + "等待");
thread1.join();
System.out.println("线程" + Thread.currentThread().getName() + "继续执行");
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
class MyThread extends Thread {
@Override
public void run() {
System.out.println("进入线程" + Thread.currentThread().getName());
try {
Thread.currentThread().sleep(5000);
} catch (InterruptedException e) {
// TODO: handle exception
}
System.out.println("线程" + Thread.currentThread().getName() + "执行完毕");
}
}
}
进入线程main
线程main等待
进入线程Thread-0
线程Thread-0执行完毕
线程main继续执行
可以看出,当调用thread1.join()方法后,main线程会进入等待,然后等待thread1执行完之后再继续执行。
实际上调用join方法是调用了Object的wait方法,这个可以通过查看源码得知:
public final synchronized void join(long millis)
throws InterruptedException {
long base = System.currentTimeMillis();
long now = 0;
if (millis < 0) {
throw new IllegalArgumentException("timeout value is negative");
}
if (millis == 0) {
while (isAlive()) {
wait(0);
}
} else {
while (isAlive()) {
long delay = millis - now;
if (delay <= 0) {
break;
}
wait(delay);
now = System.currentTimeMillis() - base;
}
}
}
wait方法会让线程进入阻塞状态,并且会释放线程占有的锁,并交出CPU执行权限。
由于wait方法会让线程释放对象锁,所以join方法同样会让线程释放对一个对象持有的锁。
public void interrupt()
interrupt,顾名思义,即中断的意思。单独调用interrupt方法可以使得处于阻塞状态的线程抛出一个异常,
也就说,它可以用来中断一个正处于阻塞状态的线程;另外,通过interrupt方法和isInterrupted()方法来停止正在运行的线程。
示例
public class InterruptT0 {
public static void main(String[] args) throws IOException {
InterruptT0 test = new InterruptT0();
MyThread thread = test.new MyThread();
thread.start();
try {
Thread.currentThread().sleep(2000);
} catch (InterruptedException e) {
}
thread.interrupt();
}
class MyThread extends Thread{
@Override
public void run() {
try {
System.out.println("进入睡眠状态");
Thread.currentThread().sleep(10000);
System.out.println("睡眠完毕");
} catch (InterruptedException e) {
System.out.println("得到中断异常");
}
System.out.println("run方法执行完毕");
}
}
}
进入睡眠状态
得到中断异常
run方法执行完毕
从这里可以看出,通过interrupt方法可以中断处于阻塞状态的线程。那么能不能中断处于非阻塞状态的线程呢?
示例
public class InterruptT0 {
public static void main(String[] args) throws IOException {
InterruptT0 test = new InterruptT0();
MyThread thread = test.new MyThread();
thread.start();
try {
Thread.currentThread().sleep(2000);
} catch (InterruptedException e) {
}
thread.interrupt();
}
class MyThread extends Thread{
@Override
public void run() {
int i = 0;
while(i<Integer.MAX_VALUE){
System.out.println(i+" while循环");
i++;
}
}
}
}
运行该程序会发现,while循环会一直运行直到变量i的值超出Integer.MAX_VALUE。所以说直接调用interrupt方法
不能中断正在运行中的线程。
但是如果配合isInterrupted()能够中断正在运行的线程,因为调用interrupt方法相当于将中断标志位置为true,
那么可以通过调用isInterrupted()判断中断标志是否被置位来中断线程的执行。比如下面这段代码:
示例
public class InterruptT0 {
public static void main(String[] args) throws IOException {
InterruptT0 test = new InterruptT0();
MyThread thread = test.new MyThread();
thread.start();
try {
Thread.currentThread().sleep(2000);
} catch (InterruptedException e) {
}
thread.interrupt();
}
class MyThread extends Thread{
@Override
public void run() {
int i = 0;
while(!isInterrupted() && i<Integer.MAX_VALUE){
System.out.println(i+" while循环");
i++;
}
}
}
}
运行会发现,打印若干个值之后,while循环就停止打印了。
但是一般情况下不建议通过这种方式来中断线程,一般会在MyThread类中增加一个属性 isStop来标志是否结束while循环,
然后再在while循环中判断isStop的值。
示例
class MyThread extends Thread{
private volatile boolean isStop =false;
@Override
public void run() {
int i = 0;
while(!isStop){
i++;
}
}
public void setStop(boolean stop){
this.isStop = stop;
}
}
那么就可以在外面通过调用setStop方法来终止while循环。
public static boolean interrupted()
interrupted()函数是Thread静态方法,用来检测当前线程的interrupt状态,检测完成后,状态清空。
通过下面的interrupted源码我们能够知道,此方法首先调用isInterrupted方法,而isInterrupted方法是
一个重载的native方法private native boolean isInterrupted(boolean ClearInterrupted)
通过方法的注释能够知道,用来测试线程是否已经中断,参数用来决定是否重置中断标志。
示例
public static boolean interrupted() {
return currentThread().isInterrupted(true);
}
public boolean isInterrupted() {
return isInterrupted(false);
}
/**
* Tests if some Thread has been interrupted. The interrupted state
* is reset or not based on the value of ClearInterrupted that is
* passed.
*/
private native boolean isInterrupted(boolean ClearInterrupted);
public final void stop()
stop方法已经是一个废弃的方法,它是一个不安全的方法。因为调用stop方法会直接终止run方法的调用,
并且会抛出一个ThreadDeath错误,如果线程持有某个对象锁的话,会完全释放锁,导致对象状态不一致。
所以stop方法基本是不会被用到的。
public void destroy()
destroy方法也是废弃的方法。基本不会被使用到。
Java并发集合
AtomicInteger
CopyOnWriteArrayList
ConcurrentHashMap
AtomicInteger
可以用原子方式更新int值。类 AtomicBoolean、AtomicInteger、AtomicLong 和 AtomicReference 的实例各自提供对相应类型单个变量的访问和更新。基本的原理都是使用CAS操作:
boolean compareAndSet(expectedValue, updateValue);
如果此方法(在不同的类间参数类型也不同)当前保持expectedValue,则以原子方式将变量设置为updateValue,并在成功时报告true。
循环CAS,参考AtomicInteger中的实现:
public final int getAndIncrement() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return current;
}
}
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
ABA问题
因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。
从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
public boolean compareAndSet(
V expectedReference,//预期引用
V newReference,//更新后的引用
int expectedStamp, //预期标志
int newStamp) //更新后的标志
CopyOnWriteArrayList
CopyOnWrite容器即写时复制的容器。往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。类似的有CopyOnWriteArraySet。
public boolean add(T e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
// 复制出新数组
Object[] newElements = Arrays.copyOf(elements, len + 1);
// 把新元素添加到新数组里
newElements[len] = e;
// 把原数组引用指向新数组
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
final void setArray(Object[] a) {
array = a;
}
读的时候不需要加锁,如果读的时候有多个线程正在向ArrayList添加数据,读还是会读到旧的数据,因为写的时候不会锁住旧的ArrayList。
public E get(int index) {
return get(getArray(), index);
}
ConcurrentHashMap
HashMap不是线程安全的。
HashTable容器使用synchronized来保证线程安全,在线程竞争激烈的情况下HashTable的效率非常低下。
ConcurrentHashMap采用了Segment分段技术,容器里有多把锁,每把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率。
ConcurrentHashMap继承结构:
既然ConcurrentHashMap使用分段锁Segment来保护不同段的数据,那么在插入和获取元素的时候,必须先通过哈希算法定位到Segment。可以看到ConcurrentHashMap会首先使用Wang/Jenkins hash的变种算法对元素的hashCode进行一次再哈希。
private static int hash(int h) {
h += (h << 15) ^ 0xffffcd7d;
h ^= (h >>> 10);
h += (h << 3);
h ^= (h >>> 6);
h += (h << 2) + (h << 14);
return h ^ (h >>> 16);
}
之所以进行再哈希,其目的是为了减少哈希冲突,使元素能够均匀的分布在不同的Segment上,从而提高容器的存取效率。假如哈希的质量差到极点,那么所有的元素都在一个Segment中,不仅存取元素缓慢,分段锁也会失去意义。
ConcurrentHashMap的get操作:
public V get(Object key) {
int hash = hash(key.hashCode());
return segmentFor(hash).get(key, hash);
}
get操作的高效之处在于整个get过程不需要加锁,除非读到的值是空的才会加锁重读。
我们知道HashTable容器的get方法是需要加锁的,那么ConcurrentHashMap的get操作是如何做到不加锁的呢?
原因是它的get方法里将要使用的共享变量都定义成volatile,如用于统计当前Segement大小的count字段和用于存储值的HashEntry的value。定义成volatile的变量,能够在线程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值,但是只能被单线程写(有一种情况可以被多线程写,就是写入的值不依赖于原值),在get操作里只需要读不需要写共享变量count和value,所以可以不用加锁。
之所以不会读到过期的值,是根据java内存模型的happen before原则,对volatile字段的写入操作先于读操作,即使两个线程同时修改和获取volatile变量,get操作也能拿到最新的值,这是用volatile替换锁的经典应用场景。
transient volatile int count;
volatile V value;
在定位元素的代码里我们可以发现定位HashEntry和定位Segment的哈希算法虽然一样,都与数组的长度减去一相与,但是相与的值不一样,定位Segment使用的是元素的hashcode通过再哈希后得到的值的高位,而定位HashEntry直接使用的是再哈希后的值。其目的是避免两次哈希后的值一样,导致元素虽然在Segment里散列开了,但是却没有在HashEntry里散列开。
hash >>> segmentShift) & segmentMask //定位Segment所使用的hash算法
int index = hash & (tab.length - 1); // 定位HashEntry所使用的hash算法
ConcurrentHashMap的put操作:
由于put方法里需要对共享变量进行写入操作,所以为了线程安全,在操作共享变量时必须得加锁。Put方法首先定位到Segment,然后在Segment里进行插入操作。插入操作需要经历两个步骤,第一步判断是否需要对Segment里的HashEntry数组进行扩容,第二步定位添加元素的位置然后放在HashEntry数组里。
是否需要扩容。在插入元素前会先判断Segment里的HashEntry数组是否超过容量(threshold),如果超过阀值,数组进行扩容。值得一提的是,Segment的扩容判断比HashMap更恰当,因为HashMap是在插入元素后判断元素是否已经到达容量的,如果到达了就进行扩容,但是很有可能扩容之后没有新元素插入,这时HashMap就进行了一次无效的扩容。
如何扩容。扩容的时候首先会创建一个两倍于原容量的数组,然后将原数组里的元素进行再hash后插入到新的数组里。为了高效ConcurrentHashMap不会对整个容器进行扩容,而只对某个segment进行扩容。
ConcurrentHashMap的size操作:
如果我们要统计整个ConcurrentHashMap里元素的大小,就必须统计所有Segment里元素的大小后求和。Segment里的全局变量count是一个volatile变量,那么在多线程场景下,我们是不是直接把所有Segment的count相加就可以得到整个ConcurrentHashMap大小了呢?不是的,虽然相加时可以获取每个Segment的count的最新值,但是拿到之后可能累加前使用的count发生了变化,那么统计结果就不准了。所以最安全的做法,是在统计size的时候把所有Segment的put,remove和clean方法全部锁住,但是这种做法显然非常低效。 因为在累加count操作过程中,之前累加过的count发生变化的几率非常小,所以ConcurrentHashMap的做法是先尝试2次通过不锁住Segment的方式来统计各个Segment大小,如果统计的过程中,容器的count发生了变化,则再采用加锁的方式来统计所有Segment的大小。
那么ConcurrentHashMap是如何判断在统计的时候容器是否发生了变化呢?使用modCount变量,在put , remove和clean方法里操作元素前都会将变量modCount进行加1,那么在统计size前后比较modCount是否发生变化,从而得知容器的大小是否发生变化。
深入锁机制
生活中用到的锁,用途都比较简单粗暴,上锁基本是为了防止外人进来、电动车被偷等等。
那在编程世界里,「锁」更是五花八门,多种多样,每种锁的加锁开销以及应用场景也可能会不同。如何用好锁,也是程序员的基本素养之一了。
高并发的场景下,如果选对了合适的锁,则会大大提高系统的性能,否则性能会降低。
所以,知道各种锁的开销,以及应用场景是很有必要的。接下来,就谈一谈常见的这几种锁:
互斥锁、自旋锁、读写锁、悲观锁、乐观锁
互斥锁与自旋锁:谁更轻松自如?
最底层的两种就是会「互斥锁和自旋锁」,有很多高级的锁都是基于它们实现的,你可以认为它们是各种锁的地基,所以我们必须清楚它俩之间的区别和应用。
加锁的目的就是多线程访问共享资源的时候,保证共享资源在任意时间里,只有一个线程访问,即在访问共享资源之前加锁,这样就可以避免多线程导致共享数据错乱的问题。
当已经有一个线程加锁后,其他线程加锁则就会失败,互斥锁和自旋锁对于加锁失败后的处理方式是不一样的:
• 互斥锁加锁失败后,线程会释放 CPU ,给其他线程;
• 自旋锁加锁失败后,线程会忙等待,直到它拿到锁;
互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞。
对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行。如下图:
所以,互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本。
那这个开销成本是什么呢?会有两次线程上下文切换的成本:
• 当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行;
• 接着,当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行。
线程的上下文切换的是什么?当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。
上下切换的耗时有大佬统计过,大概在几十纳秒到几微秒之间,如果你锁住的代码执行时间比较短,那可能上下文切换的时间都比你锁住的代码执行时间还要长。
所以,如果你能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁。
自旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。
一般加锁的过程,包含两个步骤:
• 第一步,查看锁的状态,如果锁是空闲的,则执行第二步;
• 第二步,将锁设置为当前线程持有;
CAS 函数就把这两个步骤合并成一条硬件级指令,形成原子指令,这样就保证了这两个步骤是不可分割的,要么一次性执行完两个步骤,要么两个步骤都不执行。
使用自旋锁的时候,当发生多线程竞争锁的情况,加锁失败的线程会「忙等待」,直到它拿到锁。这里的「忙等待」可以用 while 循环等待实现,不过最好是使用 CPU 提供的 PAUSE 指令来实现「忙等待」,因为可以减少循环等待时的耗电量。
自旋锁是最比较简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。需要注意,在单核 CPU 上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。
自旋锁开销少,在多核系统下一般不会主动产生线程切换,适合异步、协程等在用户态切换请求的编程方式,但如果被锁住的代码执行时间过长,自旋的线程会长时间占用 CPU 资源,所以自旋的时间和被锁住的代码执行的时间是成「正比」的关系,我们需要清楚的知道这一点。
自旋锁与互斥锁使用层面比较相似,但实现层面上完全不同:当加锁失败时,互斥锁用「线程切换」来应对,自旋锁则用「忙等待」来应对。
它俩是锁的最基本处理方式,更高级的锁都会选择其中一个来实现,比如读写锁既可以选择互斥锁实现,也可以基于自旋锁实现。
读写锁:读和写还有优先级区分?
读写锁从字面意思我们也可以知道,它由「读锁」和「写锁」两部分构成,如果只读取共享资源用「读锁」加锁,如果要修改共享资源则用「写锁」加锁。
乐观锁与悲观锁:做事的心态有何不同?
前面提到的互斥锁、自旋锁、读写锁,都是属于悲观锁。
悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁。
那相反的,如果多线程同时修改共享资源的概率比较低,就可以采用乐观锁。
乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。
放弃后如何重试,这跟业务场景息息相关,虽然重试的成本很高,但是冲突的概率足够低的话,还是可以接受的。
可见,乐观锁的心态是,不管三七二十一,先改了资源再说。另外,你会发现乐观锁全程并没有加锁,所以它也叫无锁编程。
这里举一个场景例子:在线文档。
我们都知道在线文档可以同时多人编辑的,如果使用了悲观锁,那么只要有一个用户正在编辑文档,此时其他用户就无法打开相同的文档了,这用户体验当然不好了。
那实现多人同时编辑,实际上是用了乐观锁,它允许多个用户打开同一个文档进行编辑,编辑完提交之后才验证修改的内容是否有冲突。
怎么样才算发生冲突?这里举个例子,比如用户 A 先在浏览器编辑文档,之后用户 B 在浏览器也打开了相同的文档进行编辑,但是用户 B 比用户 A 提交改动,这一过程用户 A 是不知道的,当 A 提交修改完的内容时,那么 A 和 B 之间并行修改的地方就会发生冲突。
服务端要怎么验证是否冲突了呢?通常方案如下:
• 由于发生冲突的概率比较低,所以先让用户编辑文档,但是浏览器在下载文档时会记录下服务端返回的文档版本号;
• 当用户提交修改时,发给服务端的请求会带上原始文档版本号,服务器收到后将它与当前版本号进行比较,如果版本号一致则修改成功,否则提交失败。
实际上,我们常见的 SVN 和 Git 也是用了乐观锁的思想,先让用户编辑代码,然后提交的时候,通过版本号来判断是否产生了冲突,发生了冲突的地方,需要我们自己修改后,再重新提交。
乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。
公平锁与非公平锁
公平锁与非公平锁
有五个的同学,编号分别为001到005,每天都会去排队打饭,这五名同学按先来后到的方式,先来的同学能先打到饭,在这个过程中后来的同学是不允许插队的,若每个同学都是一个线程,那么这种场景对应的锁方式就是公平锁。
突然有一天,五个同学在打饭的过程中出现了插队现象,比如编号为001,002,003,004的同学先到先排队了,005最后来排队,005本应该排在004后面的,但是005看001正好打完饭离开,他就去插队了,也就是打饭的顺序由001->002->003->004->005变为001->005->002->003->004。若每个同学都是一个线程,那么这种场景对应的锁方式就是非公平锁。
当然作为旁观者的你可能会有疑问?是不是005插到001的后面一定会成功,答案是不一定,这要看时机的。比如,005看001正好打完饭离开,下面应该是002了,可能打饭阿姨还没问002准备吃什么,就看005已经排到前面去了,那005就插队成功了,这就是时机。
[注]其实公平锁这个概念是JUC工具包才有的,比如ReentrantLock才有公平锁的概念 ,接下来结合生活中的实例来说明ReentrantLock公平锁和非公平锁,以及证明synchronized是非公平锁
非公平锁
synchronized方式
下例中,我们定义一个内部类DiningRoom表示食堂,getFood方法里面用synchronized锁修饰this指向DiningRoom的实例对象,主类中让编号001至005五个同学同时去打饭,用于测试先排队的同学是否能先打到饭?
public class SyncUnFairLockTest {
//食堂
private static class DiningRoom {
//获取食物
public void getFood() {
System.out.println(Thread.currentThread().getName()+":排队中");
synchronized (this) {
System.out.println(Thread.currentThread().getName()+":@@@@@@打饭中@@@@@@@");
}
}
}
public static void main(String[] args) {
DiningRoom diningRoom = new DiningRoom();
//让5个同学去打饭
for (int i=0; i<5; i++) {
new Thread(()->{
diningRoom.getFood();
},"同学编号:00"+(i+1)).start();
}
}
}
运行程序得到其中一种执行结果如下图所示,002->004->001->003->005同学先去排队,但打饭的顺序是002->003->001->004->005,说明这里003和001两个同学插队了,插到004前面了。
我们详细分析执行过程,002先抢到锁打饭了,释放了锁,本来应该是接下来是004抢到锁去打饭(因为004是比003先来排队),但003抢到锁,打饭了,释放了锁,这是第一次插队。现在还是来004抢锁,但是没抢到又被001抢到了,释放锁后才被004抢到,这是第二次插队,后面分别再是004->005抢到锁,释放锁,程序执行完毕。因为003和001插队,我们用代码证明了synchronized是非公平锁。
ReentrantLock方式
Lock LOCK = new ReentrantLock(false);ReentrantLock的参数是false表示非公平锁,上面代码需要用LOCK.lock()加锁,LOCK.unlock()解锁,需要放入try,finally代码块中,目的是如果try中加锁后代码发生异常锁最终执行LOCK.unlock(),锁总能被释放。
public class UnFairLockTest {
private static final Lock LOCK = new ReentrantLock(false);
//食堂
private static class DiningRoom {
//获取食物
public void getFood() {
try {
System.out.println(Thread.currentThread().getName()+":正在排队");
LOCK.lock();
System.out.println(Thread.currentThread().getName()+":@@@@@@打饭中@@@@@@@");
} catch (Exception e) {
e.printStackTrace();
} finally {
LOCK.unlock();
}
}
}
public static void main(String[] args) throws InterruptedException {
DiningRoom diningRoom = new DiningRoom();
//让5个同学去打饭
for (int i=0; i<5; i++) {
new Thread(()->{
diningRoom.getFood();
},"同学编号:00"+(i+1)).start();
}
}
}
主类中让编号001至005五个同学同时去打饭,得到其中一种执行结果如下图所示,001->004->005->003->002同学先去排队,但打饭的顺序是001->005->004->003->002,这里005同学插队了,插到004前面。我们详细分析执行过程:001先来抢到锁打饭了并释放了锁,接下来本应该是004抢到锁,因为它先排队,但005却在004之前抢到锁,打饭了,005比004后来,却先打饭,这就是不公平锁,后面的执行结果按先来后到执行,程序结束。
ReentrantLock公平锁
Java线程池
Java线程池创建
线程池工作原理
几种典型的工作队列
几种典型的拒绝策略
几种典型的线程池
Java线程池创建
无论是创建何种类型线程池(FixedThreadPool、CachedThreadPool…),均会调用ThreadPoolExecutor构造函数,下面详细解读ThreadPoolExecutor即各个参数的作用
ThreadPoolExecutor类的构造函数
public class ThreadPoolExecutor extends AbstractExecutorService {
//第一个构造
public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,
BlockingQueue<Runnable> workQueue);
//第二个构造
public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,
BlockingQueue<Runnable> workQueue,ThreadFactory threadFactory);
//第三个构造
public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,
BlockingQueue<Runnable> workQueue,RejectedExecutionHandler handler);
//第四个构造
public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,
BlockingQueue<Runnable> workQueue,ThreadFactory threadFactory,RejectedExecutionHandler handler);
...
}
ThreadPoolExecutor继承了AbstractExecutorService类,并提供了四个构造器,事实上,通过观察每个构造器的源码具体实现,发现前面三个构造器都是调用的第四个构造器进行的初始化工作。
第四个构造函数的参数含义如下:
• corePoolSize:指定了线程池中的线程可有数目,它的数量决定了添加的任务是开辟新的线程去执行,还是放到workQueue任务队列中去;默认情况下,在创建了线程池后,线程池中的线程数为0,当有任务来之后,就会创建一个线程去执行任务,当线程池中的线程数目达到corePoolSize后,就会把到达的任务放到等待队列里;
• maximumPoolSize:指定了线程池中的最大可有线程数目,这个参数会根据你使用的workQueue任务队列的类型,决定线程池会开辟的最大线程数量;
• keepAliveTime:当线程池中空闲线程数量超过corePoolSize时,多余的线程会在多长时间内被销毁;
[注]如果调用了allowCoreThreadTimeOut(boolean)方法,在线程池中的线程数不大于corePoolSize时,keepAliveTime参数也会起作用,直到线程池中的线程数为0;
• unit:keepAliveTime的单位,有7种取值,在TimeUnit类中有7种静态属性:
TimeUnit.DAYS; //天
TimeUnit.HOURS; //小时
TimeUnit.MINUTES; //分钟
TimeUnit.SECONDS; //秒
TimeUnit.MILLISECONDS; //毫秒
TimeUnit.MICROSECONDS; //微妙
TimeUnit.NANOSECONDS; //纳秒
• workQueue:任务队列,被添加到线程池中,但尚未被执行的任务;它一般分为直接提交队列、有界任务队列、无界任务队列、优先任务队列几种;
• threadFactory:线程工厂,用于创建线程,一般用默认即可;
• handler:拒绝策略;当任务太多来不及处理时,如何拒绝任务;有以下四种取值:
ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。
ThreadPoolExecutor.DiscardPolicy:也是丢弃任务,但是不抛出异常。
ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程)
ThreadPoolExecutor.CallerRunsPolicy:由调用线程处理该任务
线程池工作原理
ThreadPoolExecutor 的内部工作原理,整个思路总结起来就是 5 句话:
• 如果当前池大小 poolSize 小于 corePoolSize ,则创建新线程执行任务。
• 如果当前池大小 poolSize 大于 corePoolSize ,且等待队列未满,则进入等待队列
• 如果当前池大小 poolSize 大于 corePoolSize 且小于 maximumPoolSize ,且等待队列已满,则创建新线程执行任务。
• 如果当前池大小 poolSize 大于 corePoolSize 且大于 maximumPoolSize ,且等待队列已满,则调用拒绝策略来处理该任务。
• 线程池里的每个线程执行完任务后不会立刻退出,而是会去检查下等待队列里是否还有线程任务需要执行,如果在 keepAliveTime 里等不到新的任务了,那么线程就会退出。
几种典型的工作队列
• ArrayBlockingQueue:使用数组实现的有界阻塞队列,特性先进先出
• LinkedBlockingQueue:使用链表实现的阻塞队列,特性先进先出,可以设置其容量,默认为Interger.MAX_VALUE,特性先进先出
• PriorityBlockingQueue:使用平衡二叉树堆,实现的具有优先级的无界阻塞队列
• DelayQueue:无界阻塞延迟队列,队列中每个元素均有过期时间,当从队列获取元素时,只有
• SynchronousQueue:一个不存储元素的阻塞队列,每个插入操作,必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态
有界的任务队列:
有界的任务队列可以使用ArrayBlockingQueue实现,如下所示
pool = new ThreadPoolExecutor(1, 2, 1000, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<Runnable>(10),Executors.defaultThreadFactory(),new ThreadPoolExecutor.AbortPolicy());
使用ArrayBlockingQueue有界任务队列,若有新的任务需要执行时,线程池会创建新的线程,直到创建的线程数量达到corePoolSize时,则会将新的任务加入到等待队列中。若等待队列已满,即超过ArrayBlockingQueue初始化的容量,则继续创建线程,直到线程数量达到maximumPoolSize设置的最大线程数量,若大于maximumPoolSize,则执行拒绝策略。在这种情况下,线程数量的上限与有界任务队列的状态有直接关系,如果有界队列初始容量较大或者没有达到超负荷的状态,线程数将一直维持在corePoolSize以下,反之当任务队列已满时,则会以maximumPoolSize为最大线程数上限。
无界的任务队列:
无界任务队列可以使用LinkedBlockingQueue实现,如下所示
pool = new ThreadPoolExecutor(1, 2, 1000, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(),Executors.defaultThreadFactory(),new ThreadPoolExecutor.AbortPolicy());
使用无界任务队列,线程池的任务队列可以无限制的添加新的任务,而线程池创建的最大线程数量就是你corePoolSize设置的数量,也就是说在这种情况下maximumPoolSize这个参数是无效的,哪怕你的任务队列中缓存了很多未执行的任务,当线程池的线程数达到corePoolSize后,就不会再增加了;若后续有新的任务加入,则直接进入队列等待,当使用这种任务队列模式时,一定要注意你任务提交与处理之间的协调与控制,不然会出现队列中的任务由于无法及时处理导致一直增长,直到最后资源耗尽的问题。
直接提交队列:
工作队列的默认选项是SynchronousQueue队列,它将任务直接提交给线程而不保持它们。在此,如果不存在可用于立即运行任务的线程,则试图把任务加入队列将失败,因此会构造一个新的线程。此策略可以避免在处理可能具有内部依赖性的请求集时出现锁。直接提交通常要求无界 maximumPoolSizes 以避免拒绝新提交的任务。当命令以超过队列所能处理的平均数连续到达时,此策略允许无界线程具有增长的可能性。
public class ThreadPool {
private static ExecutorService pool;
public static void main( String[] args )
{
//maximumPoolSize设置为2 ,拒绝策略为AbortPolic策略,直接抛出异常
pool = new ThreadPoolExecutor(1, 2, 1000, TimeUnit.MILLISECONDS, new SynchronousQueue<Runnable>(),Executors.defaultThreadFactory(),new ThreadPoolExecutor.AbortPolicy());
for(int i=0;i<3;i++) {
pool.execute(new ThreadTask());
}
}
}
public class ThreadTask implements Runnable{
public ThreadTask() {
}
public void run() {
System.out.println(Thread.currentThread().getName());
}
}
pool-1-thread-1
pool-1-thread-2
Exception in thread "main" java.util.concurrent.RejectedExecutionException: Task com.hhxx.test.ThreadTask@55f96302 rejected from java.util.concurrent.ThreadPoolExecutor@3d4eac69[Running, pool size = 2, active threads = 0, queued tasks = 0, completed tasks = 2]
at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(Unknown Source)
at java.util.concurrent.ThreadPoolExecutor.reject(Unknown Source)
at java.util.concurrent.ThreadPoolExecutor.execute(Unknown Source)
at com.hhxx.test.ThreadPool.main(ThreadPool.java:17)
可以看到,当任务队列为SynchronousQueue,创建的线程数大于maximumPoolSize时,直接执行了拒绝策略抛出异常。
使用SynchronousQueue队列,提交的任务不会被保存,总是会马上提交执行。如果用于执行任务的线程数量小于maximumPoolSize,则尝试创建新的进程,如果达到maximumPoolSize设置的最大值,则根据你设置的handler执行拒绝策略。因此这种方式你提交的任务不会被缓存起来,而是会被马上执行,在这种情况下,你需要对你程序的并发量有个准确的评估,才能设置合适的maximumPoolSize数量,否则很容易就会执行拒绝策略;
优先任务队列:
优先任务队列通过PriorityBlockingQueue实现,下面我们通过一个例子演示下
public class ThreadPool {
private static ExecutorService pool;
public static void main( String[] args )
{
//优先任务队列
pool = new ThreadPoolExecutor(1, 2, 1000, TimeUnit.MILLISECONDS, new PriorityBlockingQueue<Runnable>(),Executors.defaultThreadFactory(),new ThreadPoolExecutor.AbortPolicy());
for(int i=0;i<20;i++) {
pool.execute(new ThreadTask(i));
}
}
}
public class ThreadTask implements Runnable,Comparable<ThreadTask>{
private int priority;
public int getPriority() {
return priority;
}
public void setPriority(int priority) {
this.priority = priority;
}
public ThreadTask() {
}
public ThreadTask(int priority) {
this.priority = priority;
}
//当前对象和其他对象做比较,当前优先级大就返回-1,优先级小就返回1,值越小优先级越高
public int compareTo(ThreadTask o) {
return this.priority>o.priority?-1:1;
}
public void run() {
try {
//让线程阻塞,使后续任务进入缓存队列
Thread.sleep(1000);
System.out.println("priority:"+this.priority+",ThreadName:"+Thread.currentThread().getName());
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
我们来看下执行的结果情况
priority:0,ThreadName:pool-1-thread-1
priority:9,ThreadName:pool-1-thread-1
priority:8,ThreadName:pool-1-thread-1
priority:7,ThreadName:pool-1-thread-1
priority:6,ThreadName:pool-1-thread-1
priority:5,ThreadName:pool-1-thread-1
priority:4,ThreadName:pool-1-thread-1
priority:3,ThreadName:pool-1-thread-1
priority:2,ThreadName:pool-1-thread-1
priority:1,ThreadName:pool-1-thread-1
大家可以看到除了第一个任务直接创建线程执行外,其他的任务都被放入了优先任务队列,按优先级进行了重新排列执行,且线程池的线程数一直为corePoolSize,也就是只有一个。
通过运行的代码我们可以看出PriorityBlockingQueue它其实是一个特殊的无界队列,它其中无论添加了多少个任务,线程池创建的线程数也不会超过corePoolSize的数量,只不过其他队列一般是按照先进先出的规则处理任务,而PriorityBlockingQueue队列可以自定义规则根据任务的优先级顺序先后执行。
几种典型的拒绝策略
一般我们创建线程池时,为防止资源被耗尽,任务队列都会选择创建有界任务队列,但种模式下如果出现任务队列已满且线程池创建的线程数达到你设置的最大线程数时,这时就需要你指定ThreadPoolExecutor的RejectedExecutionHandler参数即合理的拒绝策略,来处理线程池"超载"的情况。ThreadPoolExecutor自带的拒绝策略如下:
1)AbortPolicy策略:该策略会直接抛出异常,阻止系统正常工作;
2)CallerRunsPolicy策略:如果线程池的线程数量达到上限,该策略会把任务队列中的任务放在调用者线程当中运行;
3)DiscardOledestPolicy策略:该策略会丢弃任务队列中最老的一个任务,也就是当前任务队列中最先被添加进去的,马上要被执行的那个任务,并尝试再次提交;
4)DiscardPolicy策略:该策略会默默丢弃无法处理的任务,不予任何处理。当然使用此策略,业务场景中需允许任务的丢失;
以上内置的策略均实现了RejectedExecutionHandler接口,当然你也可以自己扩展RejectedExecutionHandler接口,定义自己的拒绝策略,我们看下示例代码:
public class ThreadPool {
private static ExecutorService pool;
public static void main( String[] args )
{
//自定义拒绝策略
pool = new ThreadPoolExecutor(1, 2, 1000, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<Runnable>(5),
Executors.defaultThreadFactory(), new RejectedExecutionHandler() {
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
System.out.println(r.toString()+"执行了拒绝策略");
}
});
for(int i=0;i<10;i++) {
pool.execute(new ThreadTask());
}
}
}
public class ThreadTask implements Runnable{
public void run() {
try {
//让线程阻塞,使后续任务进入缓存队列
Thread.sleep(1000);
System.out.println("ThreadName:"+Thread.currentThread().getName());
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
输出结果:
com.hhxx.test.ThreadTask@33909752执行了拒绝策略
com.hhxx.test.ThreadTask@55f96302执行了拒绝策略
com.hhxx.test.ThreadTask@3d4eac69执行了拒绝策略
ThreadName:pool-1-thread-2
ThreadName:pool-1-thread-1
ThreadName:pool-1-thread-1
ThreadName:pool-1-thread-2
ThreadName:pool-1-thread-1
ThreadName:pool-1-thread-2
ThreadName:pool-1-thread-1
可以看到由于任务加了休眠阻塞,执行需要花费一定时间,导致会有一定的任务被丢弃,从而执行自定义的拒绝策略;
几种典型的线程池
在《阿里巴巴java开发手册》中指出了线程资源必须通过线程池提供,不允许在应用中自行显示的创建线程,这样一方面是线程的创建更加规范,可以合理控制开辟线程的数量;另一方面线程的细节管理交给线程池处理,优化了资源的开销。
而线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
说明: Executors 返回的线程池对象的弊端如下:
1) FixedThreadPool 和 SingleThreadPool :允许的请求队列长度为 Integer.MAX_VALUE ,可能会堆积大量的请求,从而导致 OOM 。
2) CachedThreadPool 和 ScheduledThreadPool :允许的创建线程数量为 Integer.MAX_VALUE ,可能会创建大量的线程,从而导致 OOM 。
ThreadFactory自定义线程创建
线程池中线程就是通过ThreadPoolExecutor中的ThreadFactory,线程工厂创建的。那么通过自定义ThreadFactory,可以按需要对线程池中创建的线程进行一些特殊的设置,如命名、优先级等,下面代码我们通过ThreadFactory对线程池中创建的线程进行记录与命名
public class ThreadPool {
private static ExecutorService pool;
public static void main( String[] args )
{
//自定义线程工厂
pool = new ThreadPoolExecutor(2, 4, 1000, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<Runnable>(5),
new ThreadFactory() {
public Thread newThread(Runnable r) {
System.out.println("线程"+r.hashCode()+"创建");
//线程命名
Thread th = new Thread(r,"threadPool"+r.hashCode());
return th;
}
}, new ThreadPoolExecutor.CallerRunsPolicy());
for(int i=0;i<10;i++) {
pool.execute(new ThreadTask());
}
}
}
public class ThreadTask implements Runnable{
public void run() {
//输出执行线程的名称
System.out.println("ThreadName:"+Thread.currentThread().getName());
}
}
我们看下输出结果
线程118352462创建
线程1550089733创建
线程865113938创建
ThreadName:threadPool1550089733
ThreadName:threadPool118352462
线程1442407170创建
ThreadName:threadPool1550089733
ThreadName:threadPool1550089733
ThreadName:threadPool1550089733
ThreadName:threadPool865113938
ThreadName:threadPool865113938
ThreadName:threadPool118352462
ThreadName:threadPool1550089733
ThreadName:threadPool1442407170
可以看到线程池中,每个线程的创建我们都进行了记录输出与命名。
线程池线程数量
线程池线程数量的设置没有一个明确的指标,根据实际情况,只要不是设置的偏大和偏小都问题不大,结合下面这个公式即可
/**
* Nthreads=CPU数量
* Ucpu=目标CPU的使用率,0<=Ucpu<=1
* W/C=任务等待时间与任务计算时间的比率
*/
Nthreads = Ncpu*Ucpu*(1+W/C)
(从上面给出的ThreadPoolExecutor类的代码可以知道,ThreadPoolExecutor继承了AbstractExecutorService)
AbstractExecutorService的实现
public abstract class AbstractExecutorService implements ExecutorService {
protected <T> RunnableFuture<T> newTaskFor(Runnable runnable, T value) { };
protected <T> RunnableFuture<T> newTaskFor(Callable<T> callable) { };
public Future<?> submit(Runnable task) {};
public <T> Future<T> submit(Runnable task, T result) { };
public <T> Future<T> submit(Callable<T> task) { };
private <T> T doInvokeAny(Collection<? extends Callable<T>> tasks,
boolean timed, long nanos)
throws InterruptedException, ExecutionException, TimeoutException {
};
public <T> T invokeAny(Collection<? extends Callable<T>> tasks)
throws InterruptedException, ExecutionException {
};
public <T> T invokeAny(Collection<? extends Callable<T>> tasks,
long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException {
};
public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks)
throws InterruptedException {
};
public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks,
long timeout, TimeUnit unit)
throws InterruptedException {
};
}
(AbstractExecutorService是一个抽象类,它实现了ExecutorService接口)
ExecutorService接口的实现
public interface ExecutorService extends Executor {
void shutdown();
boolean isShutdown();
boolean isTerminated();
boolean awaitTermination(long timeout, TimeUnit unit)
throws InterruptedException;
<T> Future<T> submit(Callable<T> task);
<T> Future<T> submit(Runnable task, T result);
Future<?> submit(Runnable task);
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks)
throws InterruptedException;
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks,
long timeout, TimeUnit unit)
throws InterruptedException;
<T> T invokeAny(Collection<? extends Callable<T>> tasks)
throws InterruptedException, ExecutionException;
<T> T invokeAny(Collection<? extends Callable<T>> tasks,
long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException;
}
(ExecutorService又是继承了Executor接口)
Executor接口的实现
public interface Executor {
void execute(Runnable command);
}
流程分析
到这里,大家应该明白了ThreadPoolExecutor、AbstractExecutorService、ExecutorService和Executor几个之间的关系了。
Executor是一个顶层接口,在它里面只声明了一个方法execute(Runnable),返回值为void,参数为Runnable类型,
从字面意思可以理解,就是用来执行传进去的任务的;
然后ExecutorService接口继承了Executor接口,并声明了一些方法:submit、invokeAll、invokeAny以及shutDown等;
抽象类AbstractExecutorService实现了ExecutorService接口,基本实现了ExecutorService中声明的所有方法;
然后ThreadPoolExecutor继承了类AbstractExecutorService。
在ThreadPoolExecutor类中有几个非常重要的方法:
execute()
submit()
shutdown()
shutdownNow()
execute()方法实际上是Executor中声明的方法,在ThreadPoolExecutor进行了具体的实现,这个方法是ThreadPoolExecutor的核心方法,
通过这个方法可以向线程池提交一个任务,交由线程池去执行。
submit()方法是在ExecutorService中声明的方法,在AbstractExecutorService就已经有了具体的实现,在
ThreadPoolExecutor中并没有对其进行重写,这个方法也是用来向线程池提交任务的,但是它和execute()方法不同,
它能够返回任务执行的结果,去看submit()方法的实现,会发现它实际上还是调用的execute()方法,只不过它利用了
Future来获取任务执行结果(Future相关内容将在下一篇讲述)。
shutdown()和shutdownNow()是用来关闭线程池的。
还有很多其他的方法:
比如:getQueue() 、getPoolSize() 、getActiveCount()、getCompletedTaskCount()等获取与线程池相关属性的方法,
有兴趣的朋友可以自行查阅API。
ThreadPoolExecutor类实现原理
(在上一节从宏观上介绍了ThreadPoolExecutor,下面来深入解析一下线程池的具体实现原理)
1.线程池状态
在ThreadPoolExecutor中定义了一个volatile变量,另外定义了几个static final变量表示线程池的各个状态:
volatile int runState;
static final int RUNNING = 0;
static final int SHUTDOWN = 1;
static final int STOP = 2;
static final int TERMINATED = 3;
runState表示当前线程池的状态,它是一个volatile变量用来保证线程之间的可见性;
runState下面的几个static final变量表示runState可能的几个取值。
当创建线程池后,初始时,线程池处于RUNNING状态;
如果调用了shutdown()方法,则线程池处于SHUTDOWN状态,此时线程池不能够接受新的任务,它会等待所有任务执行完毕;
如果调用了shutdownNow()方法,则线程池处于STOP状态,此时线程池不能接受新的任务,并且会去尝试终止正在执行的任务;
当线程池处于SHUTDOWN或STOP状态,并且所有工作线程已经销毁,任务缓存队列已经清空或执行结束后,线程池被设置为TERMINATED状态。
2.任务的执行
进阶一波
多线程能提高应用吞吐量和处理速度的原因
CPU运行时,通过将于运行时间分片,通过调度来分配给各个进程线程来执行。
因为时间片非常短,所以常常让人误以为是多个线程是同时并行执行。
使用多线程来提高程序处理速度,其本质是提高对CPU的利用率。主要是两个方面
a.阻塞等待时充分利用CPU
当程序发生阻塞的操作时候,例如IO等待,CPU将就空闲下来了。而使用多线程,当一些线程发生阻塞的时候,
另一些线程则仍能利用CPU,而不至于让CPU一直空闲。
b.利用CPU的多核并行计算能力
现在的CPU基本上都是多核的。使用多线程,可以利用多核同时执行多个线程,
而不至于单线程时一个核心满载,而其他核心空闲。
多线程就一定能提高处理速度吗
不一定。当程序偏计算型的时候,盲目启动大量线程来并发,并不能提高处理速度,反而会降低处理速度。
因为在多个线程进行切换执行的时候会带一定的开销。其中有 上下文切换开销,CPU调度线程的开销,线程创建和消亡的开销等。
其中主要是上下文切换带来的开销。
上下文切换的开销
主要是来自于当线程切换时保存上一个线程现场和载入下一个线程现场的操作。
使用空循环来模拟计算性任务,看下在不同数量的线程,程序的表现
public class CounterDemo {
//总数量
private static final long num = 1000000000L;
/**
* 当累加器次数大于或等于商值时循环结束
* (总数量与线程数量的商--线程数量越少,循环次数越多)
* @param threadNum 线程数量
*/
public static void splitCount(int threadNum) {
//空循环
for (long i = 0; i < num/threadNum; i++) {
}
}
/**
* 计算程序运行时间
* @param startTime 程序开始时间
* @return
*/
public static long getIntervalTimeToNow(long startTime) {
return System.currentTimeMillis() - startTime;
}
public static void main(String[] args) throws InterruptedException {
countWithMultithread(1);
countWithMultithread(10);
countWithMultithread(100);
countWithMultithread(1000);
countWithMultithread(10000);
countWithMultithread(100000);
}
/**
*
* @param threadNum 线程数量
* @throws InterruptedException
*/
private static void countWithMultithread(final int threadNum) throws InterruptedException {
long startTime;
//使用匿名内部类的方式--进行线程任务设置
Runnable splitCount = new Runnable() {
@Override
public void run() {
//进行空循环,线程数量越少,空循环次数越多
CounterDemo.splitCount(threadNum);
}
};
List<Thread> list = new ArrayList<>();
//根据数量进行设置循环次数
for (int i = 0; i < threadNum; i++) {
Thread thread1 = new Thread(splitCount);
list.add(thread1);
}
//获取任务开始时间
startTime = System.currentTimeMillis();
//开启线程
for (Thread th: list) {
th.start();
}
for (Thread th: list) {
th.join();
}
System.out.println(String.format("%1$9d", threadNum) + " thread need:"+String.format("%1$6d",getIntervalTimeToNow(startTime)));
}
}
执行的时候,使用vmstat 查看 CS (context switch)切换次数
分析
当上下文切换最多的时候每秒切换了18W+次。从输入结果来看,线程1K,10K的时候,多线程并没有打来处理效率的提升,
反而下降了。
引起上下文切换的原因有哪些?主要有以下几种:
当前任务的时间片用完之后,系统CPU正常调度下一个任务;
当前任务碰到IO阻塞,调度线程将挂起此任务,继续下一个任务;
多个任务抢占锁资源,当前任务没有抢到,被调度器挂起,继续下一个任务;
用户代码挂起当前任务,让出CPU时间;
硬件中断
问题分析:
出现多个100原因:
– T1,T2,T3 同时执行到了System…,此时ticket–还没有执行到
出现票0,票-1 原因:
– T1线程抢到了cpu的执行权,进入到run()方法中执行,执行到if语句,就失去了cpu的执行权
…
– T2睡醒了,抢到了cpu的执行权,继续执行进行卖票
…
– T3睡醒了,抢到了cpu的执行权,继续执行进行卖票
…
此时共享资源Ticket其实已经卖出最后一张票,但T2,T3已通过if判断,则出现了0,-1的情况
在看图前,需要清楚一下java.lang包下的Object类
方法 | 内容 |
---|---|
Object clone() | 创建并返回此对象的副本 |
boolean equals(Object obj) | 指示一些其他对象是否等同于此 |
void notify | 唤醒正在等待对象监视器的单个线程 |
void notifyAll | 唤醒正在等待对象监视器的所有线程 |
void wait() | 导致当前线程等待,知道另一个线程调用对象的notify()方法或notifyAll()方法 |
void wait(long timeout) | 导致当前线程等待,直到另一个线程调用notify()方法或该对象的nofityAll方法,或者指定时间已过 |
void wait (long timeout ,int nanos) | 导致当前线程等待,直到另一个线程调用该对象的notify()方法或notifyAll()方法,或者某些线程中断当前线程,或一定量的实时时间 |
等待唤醒机制
线程间的通信
多个线程在处理同一个资源,但是处理的动作(线程的任务)却不相同
使用:wait()、notify()、sleep()
/*
*
* 进入到Timewaiting(计时等待)有两种方式
* 1.使用sleep(long m )方法,在毫秒值结束之后,线程唤醒进入到Runnable/Blocked状态
* 2.使用wait(long m ) 方法,wait()方法如果在毫秒值结束之后,还没有被notify()唤醒,就会自动醒来,线程睡醒进入到Runnable/Blocked状态
*
* 唤醒的方法:
* void notify() 唤醒在此对象监视器上等待的单个线程
* void notifyAll() 唤醒在此对象监视器上等待的所有线程
* */
public class SendThread {
public static void main (String[] args)
{
//创建锁对象,保证唯一
Object obj = new Object();
//创建一个顾客线程(消费者)
new Thread() {
@Override
public void run(){
while(true){
//保证等待和唤醒的线程只能一个执行,需要使用同步技术
synchronized (obj){
System.out.println("顾客:告知老板要的包子的种类和数量");
//调用wait()方法,放弃cpu的执行权,进入到WAITTIME状态(无线等待)
try {
obj.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
//唤醒之后执行的代码
System.out.println("顾客:包子做的好——)开吃");
System.out.println("---------------------");
}
}
}
}.start();
//创建一个老板线程(消费者)
new Thread() {
@Override
public void run(){
//保证等待和唤醒的线程只能一个执行,需要使用同步技术
while(true){
try {
//花五秒做包子
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (obj){
System.out.println("老板:包子做好,可以吃了");
//调用wait()方法,放弃cpu的执行权,进入到WAITTIME状态(无线等待)
obj.notify();
}
}
}
}.start();
}
}
控制台部分:
顾客:告知老板要的包子的种类和数量
老板:包子做好,可以吃了
顾客:包子做的好——)开吃
---------------------
顾客:告知老板要的包子的种类和数量
老板:包子做好,可以吃了
顾客:包子做的好——)开吃
---------------------
... ...
使用:wait()、notifyAll()、sleep()
public class MainThread {
public static void main (String[] args)
{
//创建锁对象,保证唯一
Object obj = new Object();
//创建一个顾客线程(消费者)
new Thread() {
@Override
public void run(){
while(true){
//保证等待和唤醒的线程只能一个执行,需要使用同步技术
synchronized (obj){
System.out.println("顾客1:告知老板要的包子的种类和数量");
//调用wait()方法,放弃cpu的执行权,进入到WAITTIME状态(无线等待)
try {
obj.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
//唤醒之后执行的代码
System.out.println("顾客1:包子做的好——)开吃");
System.out.println("---------------------");
}
}
}
}.start();
//创建顾客线程(消费者)
new Thread() {
@Override
public void run(){
while(true){
//保证等待和唤醒的线程只能一个执行,需要使用同步技术
synchronized (obj){
System.out.println("顾客2:告知老板要的包子的种类和数量");
//调用wait()方法,放弃cpu的执行权,进入到WAITTIME状态(无线等待)
try {
obj.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
//唤醒之后执行的代码
System.out.println("顾客2:包子做的好——)开吃");
System.out.println("---------------------");
}
}
}
}.start();
//创建一个老板线程(消费者)
new Thread() {
@Override
public void run(){
//保证等待和唤醒的线程只能一个执行,需要使用同步技术
while(true){
try {
//花五秒做包子
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (obj){
System.out.println("老板:包子做好,可以吃了");
//调用wait()方法,放弃cpu的执行权,进入到WAITTIME状态(无线等待)
//obj.notify(); //如果有多个等待线程,随机唤醒一个
obj.notifyAll(); //唤醒所有等待的线程
}
}
}
}.start();
}
}
控制台部分:
顾客1:告知老板要的包子的种类和数量
顾客2:告知老板要的包子的种类和数量
老板:包子做好,可以吃了
顾客1:包子做的好——)开吃
---------------------
顾客1:告知老板要的包子的种类和数量
老板:包子做好,可以吃了
顾客2:包子做的好——)开吃
---------------------
顾客2:告知老板要的包子的种类和数量
老板:包子做好,可以吃了
顾客1:包子做的好——)开吃
---------------------
... ...
高并发解决方案
当一个web系统可能出现一秒内收到数以万计甚至更多请求的情况时,系统就必须做出针对性的优化,防止陷入异常状态。
下列提供几种优化思路
请求接口的合理设计
例如一个秒杀或者抢购页面,通常分为两部分:静态的HTML内容、页面的web后台请求接口。
通常静态HTML是通过CDN部署,一般压力不大,核心瓶颈在后台请求接口上。这个后台接口必须支持高并发请求,并且要尽可能"快",在短时间里返回用户的请求结果。实现尽可能"快"可以通过后端存储使用内存级别的操作,如果还有复杂业务的需求,则建议同时采用异步写入的方式。
[扩展]还有一种偷懒的方式是采用"滞后反馈",即请求当下不知道结果一段时间后才可以从页面看到用户是否操作成功,但这种方式用户体验不好,并且容易 被用户认为暗箱操作。
异常请求的防守
例如秒杀或抢购活动开始时后台接口会收到"海量"的请求,实际上里面的水分是很大的。
不少用户为了获得商品会使用"刷票工具"或其他类型的辅助工具,还有一部分高级用户甚至会制作自动请求脚本,以此帮助他们发送尽可能多的请求到服务器来增加成功率
这些都属于"作弊的手段",具体列出如下几种场景以及应对方案:
同一个账号,一次性发出多个请求
用户通过浏览器插件在活动开始时以自己的账号一次发送上百甚至更多的请求。
这种请求在某些没有做数据安全处理的系统里,也可能造成另外一种破坏,导致某些判断条件被绕过。
例如一个简单的领取逻辑,先判断用户是否有参与记录,如果没有则领取成功,最后写入到参与记录中。这是个非常简单的逻辑,但是,在高并发的场景下,多个并发请求通过负载均衡服务器,分配到内网的多台Web服务器,它们首先向存储发送查询请求,然后,在某个请求成功写入参与记录的时间差内,其他的请求获查询到的结果都是“没有参与记录”。这里,就存在逻辑判断被绕过的风险。
应对方案:
在程序入口处设置一个账号只允许接收一个请求,其他请求过滤即可。此方法不仅解决了 同一个账号发送N个请求的问题,还保证了后续逻辑流程的安全。实现方式可以通过redis内存缓存服务,写入一个标志位,只允许一个请求写成功,结合watch的乐观锁特性,成功写入的则可以继续参加。
单台机器多个账号发送多个请求
很多公司的账号注册功能在发展早期几乎是没有限制的,很容易就可以注册很多个账号。因此,也导致了出现了一些特殊的工作室,通过编写自动注册脚本,积累了一大批“僵尸账号”,数量庞大,几万甚至几十万的账号不等,专门做各种刷的行为。举个例子,例如微博中有转发抽奖的活动,如果我们使用几万个“僵尸号”去混进去转发,这样就可以大大提升我们中奖的概率。
应对方案:
这种场景可以通过检测指定机器IP请求频率即可,如果发现某个IP请求频率很高,可以给它弹出一个验证码或者直接禁止它的请求:
弹出验证码,最核心的追求,就是分辨出真实用户。因此,大家可能经常发现,网站弹出的验证码,有些是“鬼神乱舞”的样子,有时让我们根本无法看清。他们这样做的原因,其实也是为了让验证码的图片不被轻易识别,因为强大的“自动脚本”可以通过图片识别里面的字符,然后让脚本自动填写验证码。实际上,有一些非常创新的验证码,效果会比较好,例如给你一个简单问题让你回答,或者让你完成某些简单操作(例如百度贴吧的验证码)。
直接禁止IP,实际上是有些粗暴的,因为有些真实用户的网络场景恰好是同一出口IP的,可能会有“误伤“。但是这一个做法简单高效,根据实际场景使用可以获得很好的效果。
更改IP多个账号发送多个请求
所谓道高一尺,魔高一丈。有进攻,就会有防守,永不休止。这些“工作室”,发现你对单机IP请求频率有控制之后,他们也针对这种场景,想出了他们的“新进攻方案”,就是不断改变IP。
有同学会好奇,这些随机IP服务怎么来的。有一些是某些机构自己占据一批独立IP,然后做成一个随机代理IP的服务,有偿提供给这些“工作室”使用。还有一些更为黑暗一点的,就是通过木马黑掉普通用户的电脑,这个木马也不破坏用户电脑的正常运作,只做一件事情,就是转发IP包,普通用户的电脑被变成了IP代理出口。通过这种做法,黑客就拿到了大量的独立IP,然后搭建为随机IP服务,就是为了挣钱。
应对方案:
这种场景下的请求和真实用户的行为已经基本相同,想做分辨很困难。再做进一步的限制很容易"误伤"真实用户。这个时候通常只能通过设置业务门槛来限制这种请求了,或者通过账号行为的"数据挖掘"来提前清理掉它们。
僵尸账号也还是有一些共同特征的,例如账号很可能属于同一个号码段甚至是连号的,活跃度不高,等级低,资料不全等等。根据这些特点,适当设置参与门槛,例如限制参与秒杀的账号等级。通过这些业务手段,也是可以过滤掉一些僵尸号。
在从请求来源角度对异常用户进行限制后,现在还是有数以万计的请求流入到系统中,此时就必须要关注共享数据安全性和锁性能两个方面的问题了,在并发编程领域的讨论中几乎百分之九十以上都是围绕这两大主题展开的,甚至JDK每个版本的升级都有针对这两方面问题做的优化,如JDK5之后的各种锁优化技术,volatile,threadlocal关键字等。