对高并发理解
高并发是一种高效解决问题的思维模式。上小学的时候,老师问过这样一个问题:烧开水需要20分钟,洗碗需要20分钟,请问小明如何在30分钟内做完两件事。作为一个智力正常的成年人肯定很快就能想到方案,不过当时我是没想到可以两个事情同时做,所以现在还对这简单问题耿耿于怀。
宏观理解
背景
随着计算机性能和互联网的发展普及,软件系统需要处理的业务复杂度和吞吐率有了巨大变化,为了应对需求的,软件架构也一直发生着演化。
早期的单体架构,一台服务器存储数据,也做业务计算,主要面向的企业应用和早期的网站。特点是使用人数不多,业务简单。
近期的分布式多层微服务架构,此架构通常是一个集群服务模式,可以对某个服务进行在线扩展,集群规模可达数万台。此架构主要面向大规模用户访问的互联网应用。特点是并发量大,业务伸缩性强。
宏观层高并发思路
通过单体架构向分布式多层微服务的架构变化,可以看出在宏观层面,高并发的解决方案分布式集群,因此解决集群组织问题是高并发的核心。在单体架构模式下相当于是一个人自产自销的小农模式,结构简单,无需关注外部变化;演变到分布式多层微服务架构的时候相当于是一个超级跨国公司,因此如何组织好各个部分,让他们有序高效的协同工作就是核心的挑战。
微观理解
背景
并发是指一段时间内,有多个任务工作
并行是指同一时间点,有多个任务同时工作
并发和并行
并发和并行是两个不同的概念。在单核CPU下是无法完成并行的,但是可以通过CPU时钟分片的方式完成并发。使用并发的根本原因在于CPU处理指令的速度远大于IO(磁盘IO,网络IO)的速度。
假设有一个对10GB数据的进行排序需求,CPU完成排序可能只需要1分钟,但是这些数据从磁盘读取到内存,再到缓存可能需要20分钟,如果不做并发处理,相当于19分钟CPU是空闲的,便会造成一种资源浪费。总的来说并发会带来三个好处:
更合理利用CPU资源,尽量减少CPU的空闲时间
优化程序设计,不同任务由不同的线程处理,结构更加清晰
及时响应,并发结合异步等手段可实现及时相应用户请求
微观层高并发思路
微观层高并发的核心需求是能够提高CPU的利用率,或者说降低CPU速度(Vc)和IO速度(Ic)的比值(Vc/Ic)。通常任务层可以通过线程的方式实现多任务并发处理,IO层可以通过缓存的方式提高数据读取速度。但多任务、缓存之间较之单任务会带来缓存一致性等问题,也就是不同任务处理同一份数据,如何保证计算结果是预期的。通常并发任务为了保证预期结果需要确保:
原子性 即一个或者多个操作作为一个整体,要么全部执行,要么都不执行,并且操作在执行过程中不会被线程调度机制打断;而且这种操作一旦开始,就一直运行到结束,中间不会有任何上下文切换。
可见性 是指当多个任务访问同一个内存变量时,一个线程修改了这个内存变量的值,其他线程能够立即看得到修改的值。
有序性 即任务执行的顺序按照任务定义流程的先后顺序执行
JAVA高并发编程
Java语言内置了多线程支持。Java程序是运行于JVM(Java 虚拟机)中的,一个Java程序实际上是一个JVM进程,进程中启动了一个主线程来执行main()
方法(Java 入口函数),在main()
方法中又可以启动其他线程。此外,JVM中也有工作线程,如垃圾回收。
对于Java程序来说,实际上就是处于多线程的编程环境中。
JVM和JAVA内存模型(JMM)
JVM(Java Virtual Machine) 是一种基于计算设备的规范,是一台虚拟机,即虚构的计算机。
JMM(Java Memory Model) 是一种规范了Java虚拟机与计算机内存是如何协同工作的协议。
JVM概要介绍
类加载器 加载Java字节码文件,JVM入口
Java栈 本地方法栈、程序计算器,Java线程运行存储数据结构
方法区 存储常量、类信息、接口信息、方法信息等元数据
堆 存储程序创建的对象,垃圾回收区域
执行引擎 调用操作系统执行指令的出口
例如下程序:
public class Jvm {
final int var1 = 0;
static int var2 = 1;
public void test(){
int localVar = 3;
System.out.println("test is run");
}
public static void main(String[] args){
Jvm jvm = new Jvm();
jvm.test();
}
}
Jvm.java通过javac 编译成Jvm.class后由类加载器加载到JVM中,其中因为var1
、var2
被static
、final
修饰,可以理解为常量,同test()
方法等类元数据信息存储于方法区。new Jvm()
出来的jvm
对象则存储于堆内存中。此程序是运行于main
线程中,因此有一个主线程Java栈内存,存储localVar
这样的局部变量、程序计算器、输入输出参数、对象引用地址等。执行的过程通过执行引擎调用系统接口执行指令完成任务。最终运行完成后JVM进程完成垃圾回收,退出进程。
JMM概要介绍
现代计算机内存模型
现代物理计算机大多数也是多核模型,任务能够并行运行于不同核上。当不同任务对共享的数据进行读写的时候,需要和内存交互。为了降低CPU运输速度和内存IO速度的大小,现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。但是提供速度的同时也引入了缓存一致性(Cache Coherence)问题。为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。
JMM
Java内存模型中规定了所有变量都存贮到主内存(如虚拟机物理内存中的一部分)中。每一个线程都有一个自己的工作内存(如cpu中的高速缓存)。线程中的工作内存保存了该线程使用到的变量的主内存的副本拷贝。线程对变量的所有操作(读取、赋值等)必须在该线程的工作内存中进行。不同线程之间无法直接访问对方工作内存中变量。线程间变量的值传递均需要通过主内存来完成。
JMM定义了8种操作来控制变量读写。
lock(锁定):作用于主内存,它把一个变量标记为一条线程独占状态;
read(读取):作用于主内存,它把变量值从主内存传送到线程的工作内存中,以便随后的load动作使用;
load(载入):作用于工作内存,它把read操作的值放入工作内存中的变量副本中;
use(使用):作用于工作内存,它把工作内存中的值传递给执行引擎,每当虚拟机遇到一个需要使用这个变量的指令时候,将会执行这个动作;
assign(赋值):作用于工作内存,它把从执行引擎获取的值赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的指令时候,执行该操作;
store(存储):作用于工作内存,它把工作内存中的一个变量传送给主内存中,以备随后的write操作使用;
write(写入):作用于主内存,它把store传送值放到主内存中的变量中。
unlock(解锁):作用于主内存,它将一个处于锁定状态的变量释放出来,释放后的变量才能够被其他线程锁定;
public class Jmm {
static volatile int shareVar = 0;
public static void main(String[] args){
new Thread(new Runnable() {
@Override
public void run() {
while (shareVar == 0){
}
System.out.println("I known var updated");
}
}).start();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(new Runnable() {
@Override
public void run() {
updateVarVal();
}
}).start();
}
public static void updateVarVal(){
shareVar = 1;
}
}
如上程序JMM控制的流程如下:
程序启动后shareVar
将存入共享变量中,同时两个线程各自通过JMM操作将共享变量副本存入自己的工作内存,当值有更新的时候再刷入共享内存,并通知总线有值更新,线程收到总线消息后把工作内存的值失效,然后从共享内存读取最新的值。
线程生命周期
在Java程序中,一个线程对象只能调用一次start()方法启动新线程,并在新线程中执行run()方法。一旦run()方法执行完毕,线程就结束了。因此,Java线程的状态有以下几种:
New 新创建的线程,尚未执行;
Runnable 运行中的线程,正在执行run()方法的Java代码;
Blocked 运行中的线程,因为某些操作被阻塞而挂起;
Waiting 运行中的线程,因为某些操作在等待中;
Timed Waiting 运行中的线程,因为执行sleep()方法正在计时等待;
Terminated 线程已终止,因为run()方法执行完毕。
内存可见性
在Java中使用volatile
关键字来保证变量的一致性。
public class Volatile {
private static volatile int number = 0;
//每个线程会存一个变量副本
private static class ReaderThread extends Thread {
@Override
public void run() {
int local = number;
while (local < 5) {
if(local != number){
System.out.println("Got Change for MY_INT : " + number);
local = number;
}
}
}
}
private static class WriterThread extends Thread {
@Override
public void run() {
int local = number;
while (local < 5) {
System.out.println("Incrementing MY_INT to " + (local + 1));
number = ++local;
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) {
new ReaderThread().start();
new WriterThread().start();
System.out.println("end");
}
}
本案例中,读线程和写线程共享变量number
,写线程更新值,读线程读取值。如果不用volatile
修饰共享变量,在写线程更新了值后读线程却得不到最新的值,所以就有了可见性
问题。因此,volatile
可以解决可见性和有序性的问题。
线程同步
Java通过synchronized
来实现线程的同步,也就是保证操作原子性。
public class SyncFunc {
public static void main(String[] args) throws InterruptedException {
Thread t1 = new MyAddThread();
Thread t2 = new MyDecThread();
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count is " + Counter.count);
Thread st1 = new SyncMyAddThread();
Thread st2 = new SyncMyDecThread();
st1.start();
st2.start();
st1.join();
st2.join();
System.out.println("count is " + SyncCounter.count);
}
static class Counter {
static int count = 0;
}
static class MyAddThread extends Thread{
public void run(){
for(int i = 0; i < 100000; i++){
Counter.count += 1;
}
}
}
static class MyDecThread extends Thread{
public void run(){
for(int i = 0; i < 100000; i++){
Counter.count -= 1;
}
}
}
static class SyncCounter {
final static Object lock = new Object();
static int count = 0;
}
static class SyncMyAddThread extends Thread{
public void run(){
for(int i = 0; i < 100000; i++){
//不同线程锁同一个对象
synchronized (SyncCounter.lock){
SyncCounter.count += 1;
}
}
}
}
static class SyncMyDecThread extends Thread{
public void run(){
for(int i = 0; i < 100000; i++){
//不同线程锁同一个对象
synchronized (SyncCounter.lock) {
SyncCounter.count -= 1;
}
}
}
}
}
本案例中有一个累加线程对对象变量count
进行累加,另外一个线程进行累减。一个场景没加同步,另外一个对对象加了同步。因此得到的输出是完全不一样的,没加同步的每次结果可能都不一样,加了同步的结果都是0。需要注意的同步锁住的对象级别的,如果不同线程锁的对象不是一个,会导致意外发生。
no synchronized: count is 10675
synchronized: count is 0
死锁
死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
public class DeadLock {
public static void main(String[] args) {
Counter c1 = new Counter();
new Thread(() -> {
try {
c1.add(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
new Thread(() -> {
try {
c1.dec(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
static class Lock{
static final Object lockA = new Object();
static final Object lockB = new Object();
}
static class Counter{
int valueA;
int valueB;
public void add(int m) throws InterruptedException {
System.out.println(new Date().toString() + " add 开始执行");
synchronized (Lock.lockA){
this.valueA += m;
System.out.println(new Date().toString() + " add 锁住 lockA");
Thread.sleep(3000); // 此处等待是给B能锁住机
synchronized (Lock.lockB){
this.valueB += m;
System.out.println(new Date().toString() + " add 锁住 lockB");
Thread.sleep(60 * 1000); // 为测试,占用了就不放
}
}
}
public void dec(int m) throws InterruptedException {
System.out.println(new Date().toString() + " dec 开始执行");
synchronized (Lock.lockB){
this.valueA -= m;
System.out.println(new Date().toString() + " dec 锁住 lockB");
Thread.sleep(3000); // 此处等待是给B能锁住机
synchronized (Lock.lockA){
this.valueB -= m;
System.out.println(new Date().toString() + " dec 锁住 lockA");
Thread.sleep(60 * 1000); // 为测试,占用了就不放
}
}
}
}
}
此案例中有两个变量valueA
、valueB
和对象lockA
、lockB
。有两个线程,一个线程先对对象lockA
加锁,然后对变量valueA
累加操作和休眠一段时间(假设这过程比较耗时),再加锁lockB
对象和操作valueB
;另外一个线程做相反操作,先对对象localB
锁和操作变量valueA
和休眠一段时间(假设这过程比较耗时),再加锁lockA
和操作变量valueB
。这样就会发现程序卡死了,分析java执行日志信息可以发现发生死锁的信息。通常发生死锁需要三个条件:
竞争同一个资源
请求资源的方向相反
持续请求和保持
线程间通信
Java中通过notify
和wait
完成线程之间的通信。
public class WaitAndNotify {
public static void main(String[] args) {
Task q = new Task();
for (int i = 0; i < 100; i++) {
new Thread(() -> {
String s = null;
try {
s = q.get();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("get:" + s);
}).start();
}
new Thread(() -> {
for (int i = 0; i < 100; i++) {
String s = "t-" + i;
System.out.println("add task: " + s);
q.add(s);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
}
}).start();
}
static class Task {
Queue<String> taskQueue = new LinkedList<>();
public synchronized void add(String s) {
taskQueue.add(s);
this.notifyAll();
}
public synchronized String get() throws InterruptedException {
while (taskQueue.isEmpty()) {
this.wait();
}
return taskQueue.remove();
}
}
}
此案例中有一个任务队列taskQueue
,一个生产任务线程往队列里添加任务,一个消费线程当队列里有新任务时候取出。所以当添加任务的时候调用notifyAll
通知所有监听的消费线程有新任务,消费线程一直判断队列是否为空,如果为空就调用wait
来表示正在监听,不为空就取出任务。
总结
不是多线程的程序,可以先写代码,再重构优化
多线程的程序,一定要先设计,再写代码
多线程编程的时候,要时刻想象同时很多个线程同时执行时是否还能保证预期