选择多线程的原因,就是因为快。举个例子:
如果要把1000块砖搬到楼顶,假设到楼顶有几个电梯,你觉得用一个电梯搬运快,还是同时用几个电梯同时搬运快呢?这个电梯就可以理解为线程。
所以,我们使用多线程就是因为: 在正确的场景下,设置恰当数目的线程,可以用来程提高序的运行速率。更专业点讲,就是充分地利用CPU和I/O的利用率,提升程序运行速率。
当然,有利就有弊,多线程场景下,我们要保证线程安全,就需要考虑加锁。加锁如果不恰当,就很很耗性能。
Java中创建线程主要有以下这几种方式:
定义Thread
类的子类,并重写该类的run
方法
定义Runnable
接口的实现类,并重写该接口的run()
方法
定义Callable
接口的实现类,并重写该接口的call()
方法,一般配合Future
使用
线程池的方式
public class ThreadTest { public static void main(String[] args) { Thread thread = new MyThread(); thread.start(); } } classMyThread extends Thread { @Override public void run() { System.out.println
public class ThreadTest { public static void main(String[] args) { MyRunnable myRunnable = new MyRunnable(); Thread thread = newThread(myRunnable); thread.start(); } } class MyRunnableimplements Runnable { @Override public void run() { System.out.println
如果想要执行的线程有返回,可以使用Callable
。
public class ThreadTest { public static void main(String[] args) throws ExecutionException, InterruptedException { MyThreadCallable mc = new MyThreadCallable(); FutureTask
日常开发中,我们一般都是用线程池的方式执行异步任务。
public class ThreadTest { public static void main(String[] args) throws Exception { ThreadPoolExecutor executorOne = newThreadPoolExecutor(5, 5, 1, TimeUnit.MINUTES, newArrayBlockingQueue
其实start
和run
的主要区别如下:
start
方法可以启动一个新线程,run
方法只是类的一个普通方法而已,如果直接调用run
方法,程序中依然只有主线程这一个线程。
start
方法实现了多线程,而run
方法没有实现多线程。
start
不能被重复调用,而run
方法可以。
start
方法中的run
代码可以不执行完,就继续执行下面的代码,也就是说进行了线程切换。然而,如果直接调用run
方法,就必须等待其代码全部执行完才能继续执行下面的代码。
大家可以结合代码例子来看看哈~
public class ThreadTest { public static void main(String[] args){ Thread t=new Thread(){ public void run(){ pong(); } }; t.start(); t.run(); t.run(); System.out.println"+ Thread.currentThread().getName()); } static void pong(){ System.out.println+ Thread.currentThread().getName()); } }
进程是运行中的应用程序,线程是进程的内部的一个执行序列
进程是资源分配的最小单位,线程是CPU调度的最小单位。
一个进程可以有多个线程。线程又叫做轻量级进程,多个线程共享进程的资源
进程间切换代价大,线程间切换代价小
进程拥有资源多,线程拥有资源少地址
进程是存在地址空间的,而线程本身无地址空间,线程的地址空间是包含在进程中的
举个例子:
你打开QQ,开了一个进程;打开了迅雷,也开了一个进程。
在QQ的这个进程里,传输文字开一个线程、传输语音开了一个线程、弹出对话框又开了一个线程。
所以运行某个软件,相当于开了一个进程。在这个软件运行的过程里(在这个进程里),多个工作支撑的完成QQ的运行,那么这“多个工作”分别有一个线程。
所以一个进程管着多个线程。
通俗的讲:“进程是爹妈,管着众多的线程儿子”...
Runnable
接口中的run()
方法没有返回值,是void
类型,它做的事情只是纯粹地去执行run()
方法中的代码而已;
Callable
接口中的call()
方法是有返回值的,是一个泛型。它一般配合Future、FutureTask
一起使用,用来获取异步执行的结果。
Callable
接口call()
方法允许抛出异常;而Runnable
接口run()
方法不能继续上抛异常;
大家可以看下它俩的API
:
@FunctionalInterface public interface Callable
为了方便大家理解,写了一个demo,小伙伴们可以看看哈:
* @date 2022-07-11 */ public class CallableRunnableTest { public static void main(String[] args) { ExecutorService executorService =Executors.newFixedThreadPool(5); Callable
volatile关键字是Java虚拟机提供的的最轻量级的同步机制。它作为一个修饰符,用来修饰变量。它保证变量对所有线程可见性,禁止指令重排,但是不保证原子性。
我们先来一起回忆下java内存模型(jmm):
Java虚拟机规范试图定义一种Java内存模型,来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台上都能达到一致的内存访问效果。
Java内存模型规定所有的变量都是存在主内存当中,每个线程都有自己的工作内存。这里的变量包括实例变量和静态变量,但是不包括局部变量,因为局部变量是线程私有的。
线程的工作内存保存了被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接操作主内存。并且每个线程不能访问其他线程的工作内存。
volatile变量,保证新值能立即同步回主内存,以及每次使用前立即从主内存刷新,所以我们说volatile保证了多线程操作变量的可见性。
volatile保证可见性和禁止指令重排,都跟内存屏障有关。我们来看一段volatile使用的demo代码:
public class Singleton { private volatile static Singleton instance; private Singleton (){} public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } } 复制代码
编译后,对比有volatile
关键字和没有volatile
关键字时所生成的汇编代码,发现有volatile
关键字修饰时,会多出一个lock addl $0x0,(%esp)
,即多出一个lock前缀指令,lock指令相当于一个内存屏障
lock
指令相当于一个内存屏障,它保证以下这几点:
重排序时不能把后面的指令重排序到内存屏障之前的位置
将本处理器的缓存写入内存
如果是写入动作,会导致其他处理器中对应的缓存无效。
第2点和第3点就是保证volatile
保证可见性的体现嘛,第1点就是禁止指令重排的体现。
内存屏障四大分类:(Load 代表读取指令,Store代表写入指令)
在每个volatile写操作的前面插入一个StoreStore屏障。
在每个volatile写操作的后面插入一个StoreLoad屏障。
在每个volatile读操作的后面插入一个LoadLoad屏障。
在每个volatile读操作的后面插入一个LoadStore屏障。
有些小伙伴,可能对这个还是有点疑惑,内存屏障这玩意太抽象了。我们照着代码看下吧:
内存屏障保证前面的指令先执行,所以这就保证了禁止了指令重排啦,同时内存屏障保证缓存写入内存和其他处理器缓存失效,这也就保证了可见性,哈哈有关于volatile的底层实现,我们就讨论到这哈
并发和并行最开始都是操作系统中的概念,表示的是CPU执行多个任务的方式。
顺序:上一个开始执行的任务完成后,当前任务才能开始执行
并发:无论上一个开始执行的任务是否完成,当前任务都可以开始执行
(即 A B 顺序执行的话,A 一定会比 B 先完成,而并发执行则不一定。)
串行:有一个任务执行单元,从物理上就只能一个任务、一个任务地执行
并行:有多个任务执行单元,从物理上就可以多个任务一起执行
(即在任意时间点上,串行执行时必然只有一个任务在执行,而并行则不一定。)
知乎有个很有意思的回答,大家可以看下:
你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行。
你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。
你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。
并发的关键是你有处理多个任务的能力,不一定要同时。并行的关键是你有同时处理多个任务的能力。所以我认为它们最关键的点就是:是否是同时。
来源:知乎
synchronized是Java中的关键字,是一种同步锁。synchronized关键字可以作用于方法或者代码块。
一般面试时。可以这么回答:
如果synchronized作用于代码块,反编译可以看到两个指令:monitorenter、monitorexit
,JVM使用monitorenter和monitorexit
两个指令实现同步;如果作用synchronized作用于方法,反编译可以看到ACCSYNCHRONIZED标记
,JVM通过在方法访问标识符(flags)中加入ACCSYNCHRONIZED
来实现同步功能。
同步代码块是通过monitorenter和monitorexit
来实现,当线程执行到monitorenter的时候要先获得monitor锁,才能执行后面的方法。当线程执行到monitorexit的时候则要释放锁。
同步方法是通过中设置ACCSYNCHRONIZED标志来实现,当线程执行有ACCSYNCHRONI标志的方法,需要获得monitor锁。每个对象都与一个monitor相关联,线程可以占有或者释放monitor。
monitor是什么呢?操作系统的管程(monitors)是概念原理,ObjectMonitor是它的原理实现。
在Java虚拟机(HotSpot)中,Monitor(管程)是由ObjectMonitor实现的,其主要数据结构如下:
ObjectMonitor() { _header = NULL; _count = 0; // 记录个数 _waiters = 0, _recursions = 0; _object = NULL; _owner = NULL; _WaitSet = NULL; // 处于wait状态的线程,会被加入到_WaitSet _WaitSetLock = 0 ; _Responsible = NULL ; _succ = NULL ; _cxq = NULL ; FreeNext = NULL ; _EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表 _SpinFreq = 0 ; _SpinClock = 0 ; OwnerIsThread = 0 ; } 复制代码
ObjectMonitor中几个关键字段的含义如图所示:
想要获取monitor的线程,首先会进入_EntryList队列。
当某个线程获取到对象的monitor后,进入Owner区域,设置为当前线程,同时计数器count加1。
如果线程调用了wait()方法,则会进入WaitSet队列。它会释放monitor锁,即将owner赋值为null,count自减1,进入WaitSet队列阻塞等待。
如果其他线程调用 notify() / notifyAll() ,会唤醒WaitSet中的某个线程,该线程再次尝试获取monitor锁,成功即进入Owner区域。
同步方法执行完毕了,线程退出临界区,会将monitor的owner设为null,并释放监视锁。
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header),实例数据(Instance Data)和对象填充(Padding)。
对象头主要包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针)。
Mark Word 是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。
重量级锁,指向互斥量的指针。其实synchronized是重量级锁,也就是说Synchronized的对象锁,Mark Word锁标识位为10,其中指针指向的是Monitor对象的起始地址。
线程有6个状态,分别是:New, Runnable, Blocked, Waiting, Timed_Waiting, Terminated
。
转换关系图如下:
New:线程对象创建之后、但还没有调用start()
方法,就是这个状态。
public class ThreadTest { public static void main(String[] args) { Thread thread = new Thread(); System.out.println(thread.getState()); } } //运行结果: NEW 复制代码
Runnable:它包括就绪(ready
)和运行中(running
)两种状态。如果调用start
方法,线程就会进入Runnable
状态。它表示我这个线程可以被执行啦(此时相当于ready
状态),如果这个线程被调度器分配了CPU时间,那么就可以被执行(此时处于running
状态)。
public class ThreadTest { public static void main(String[] args) { Thread thread = new Thread(); thread.start(); System.out.println(thread.getState()); } } //运行结果: RUNNABLE 复制代码
Blocked: 阻塞的(被同步锁或者IO锁阻塞)。表示线程阻塞于锁,线程阻塞在进入synchronized
关键字修饰的方法或代码块(等待获取锁)时的状态。比如前面有一个临界区的代码需要执行,那么线程就需要等待,它就会进入这个状态。它一般是从RUNNABLE
状态转化过来的。如果线程获取到锁,它将变成RUNNABLE
状态
Thread t = new Thread(new Runnable { void run() { synchronized (lock) { // 阻塞于这里,变为Blocked状态 // dothings } } }); t.getState(); //新建之前,还没开始调用start方法,处于New状态 t.start(); //调用start方法,就会进入Runnable状态 复制代码
WAITING : 永久等待状态,进入该状态的线程需要等待其他线程做出一些特定动作(比如通知)。处于该状态的线程不会被分配CPU执行时间,它们要等待被显式地唤醒,否则会处于无限期等待的状态。一般Object.wait
。
Thread t = new Thread(new Runnable { void run() { synchronized (lock) { // Blocked // dothings while (!condition) { lock.wait(); // into Waiting } } } }); t.getState(); // New t.start(); // Runnable 复制代码
TIMED_WATING: 等待指定的时间重新被唤醒的状态。有一个计时器在里面计算的,最常见就是使用Thread.sleep
方法触发,触发后,线程就进入了Timed_waiting
状态,随后会由计时器触发,再进入Runnable
状态。
Thread t = new Thread(new Runnable { void run() { Thread.sleep(1000); // Timed_waiting } }); t.getState(); // New t.start(); // Runnable 复制代码
终止(TERMINATED):表示该线程已经执行完成。
再来看个代码demo吧:
*/ public class ThreadTest { private static Object object = new Object(); public static void main(String[] args) throws Exception { Thread thread = new Thread(new Runnable() { @Override public void run() { try { for(int i = 0; i< 1000; i++){ System.out.print(""); } Thread.sleep(500); synchronized (object){ object.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } } }); Thread thread1 = new Thread(new Runnable() { @Override public void run() { try { synchronized (object){ Thread.sleep(1000); } Thread.sleep(1000); synchronized (object){ object.notify(); } } catch (InterruptedException e) { e.printStackTrace(); } } }); System.out.println("1"+thread.getState()); thread.start(); thread1.start(); System.out.println("2"+thread.getState()); while (thread.isAlive()){ System.out.println("---"+thread.getState()); Thread.sleep(100); } System.out.println("3"+thread.getState()); } } 运行结果: 1NEW 2RUNNABLE ---RUNNABLE ---TIMED_WAITING ---TIMED_WAITING ---TIMED_WAITING ---TIMED_WAITING ---BLOCKED ---BLOCKED ---BLOCKED ---BLOCKED ---BLOCKED ---WAITING ---WAITING ---WAITING ---WAITING ---WAITING ---WAITING ---WAITING ---WAITING ---WAITING 复制代码
Synchronized
是依赖于JVM
实现的,而ReenTrantLock
是API
实现的。
在Synchronized
优化以前,synchronized
的性能是比ReenTrantLock
差很多的,但是自从Synchronized
引入了偏向锁,轻量级锁(自旋锁)后,两者性能就差不多了。
Synchronized
的使用比较方便简洁,它由编译器去保证锁的加锁和释放。而ReenTrantLock
需要手工声明来加锁和释放锁,最好在finally中声明释放锁。
ReentrantLock
可以指定是公平锁还是⾮公平锁。⽽synchronized
只能是⾮公平锁。
ReentrantLock
可响应中断、可轮回,而Synchronized
是不可以响应中断的
wait()
方法使得线程进入阻塞等待状态,并且释放锁
notify()
唤醒一个处于等待状态的线程,它一般跟wait()
方法配套使用。
suspend()
使得线程进入阻塞状态,并且不会自动恢复,必须对应的resume()
被调用,才能使得线程重新进入可执行状态。suspend()
方法很容易引起死锁问题。
resume()
方法跟suspend()
方法配套使用。
suspend()不建议使用,因为suspend()
方法在调用后,线程不会释放已经占有的资 源(比如锁),而是占有着资源进入睡眠状态,这样容易引发死锁问题。
CAS
,全称是Compare and Swap
,翻译过来就是比较并交换;
CAS
涉及3个操作数,内存地址值V,预期原值A,新值B;如果内存位置的值V与预期原A值相匹配,就更新为新值B,否则不更新
CAS有什么缺陷?
ABA 问题
并发环境下,假设初始条件是A,去修改数据时,发现是A就会执行修改。但是看到的虽然是A,中间可能发生了A变B,B又变回A的情况。此时A已经非彼A,数据即使成功修改,也可能有问题。
可以通过AtomicStampedReference
解决ABA问题,它,一个带有标记的原子引用类,通过控制变量值的版本来保证CAS的正确性。
循环时间长开销
自旋CAS,如果一直循环执行,一直不成功,会给CPU带来非常大的执行开销。很多时候,CAS思想体现,是有个自旋次数的,就是为了避开这个耗时问题~
只能保证一个变量的原子操作。
CAS 保证的是对一个变量执行操作的原子性,如果对多个变量操作时,CAS 目前无法直接保证操作的原子性的。可以通过这两个方式解决这个问题:1. 使用互斥锁来保证原子性; 2.将多个变量封装成对象,通过AtomicReference来保证原子性。
有兴趣的朋友可以看看我之前的这篇实战文章哈~CAS乐观锁解决并发问题的一次实践
CountDownLatch和CyclicBarrier
都用于让线程等待,达到一定条件时再运行。主要区别是:
CountDownLatch:一个或者多个线程,等待其他多个线程完成某件事情之后才能执行;
CyclicBarrier:多个线程互相等待,直到到达同一个同步点,再继续一起执行。
举个例子吧:
CountDownLatch:假设老师跟同学约定周末在公园门口集合,等人齐了再发门票。那么,发门票(这个主线程),需要等各位同学都到齐(多个其他线程都完成),才能执行。
CyclicBarrier:多名短跑运动员要开始田径比赛,只有等所有运动员准备好,裁判才会鸣枪开始,这时候所有的运动员才会疾步如飞。
CPU的缓存是以缓存行(cache line)为单位进行缓存的,当多个线程修改相互独立的变量,而这些变量又处于同一个缓存行时就会影响彼此的性能。这就是伪共享
现代计算机计算模型:
CPU执行速度比内存速度快好几个数量级,为了提高执行效率,现代计算机模型演变出CPU、缓存(L1,L2,L3),内存的模型。
CPU执行运算时,如先从L1缓存查询数据,找不到再去L2缓存找,依次类推,直到在内存获取到数据。
为了避免频繁从内存获取数据,聪明的科学家设计出缓存行,缓存行大小为64字节。
也正是因为缓存行的存在,就导致了伪共享问题,如图所示:
假设数据a、b
被加载到同一个缓存行。
当线程1修改了a的值,这时候CPU1就会通知其他CPU核,当前缓存行(Cache line)已经失效。
这时候,如果线程2发起修改b,因为缓存行已经失效了,所以「core2 这时会重新从主内存中读取该 Cache line 数据」。读完后,因为它要修改b的值,那么CPU2就通知其他CPU核,当前缓存行(Cache line)又已经失效。
酱紫,如果同一个Cache line的内容被多个线程读写,就很容易产生相互竞争,频繁回写主内存,会大大降低性能。
既然伪共享是因为相互独立的变量存储到相同的Cache line导致的,一个缓存行大小是64字节。那么,我们就可以使用空间换时间的方法,即数据填充的方式,把独立的变量分散到不同的Cache line~
来看个例子:
*/ public class FalseShareTest { public static void main(String[] args) throws InterruptedException { Rectangle rectangle = new Rectangle(); long beginTime = System.currentTimeMillis(); Thread thread1 = new Thread(() -> { for (int i = 0; i < 100000000; i++) { rectangle.a = rectangle.a + 1; } }); Thread thread2 = new Thread(() -> { for (int i = 0; i < 100000000; i++) { rectangle.b = rectangle.b + 1; } }); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.println("执行时间" + (System.currentTimeMillis() - beginTime)); } } class Rectangle { volatile long a; volatile long b; } //运行结果: 执行时间2815 复制代码
一个long类型是8字节,我们在变量a和b之间不上7个long类型变量呢,输出结果是啥呢?如下:
class Rectangle { volatile long a; long a1,a2,a3,a4,a5,a6,a7; volatile long b; } //运行结果 执行时间1113 复制代码
可以发现利用填充数据的方式,让读写的变量分割到不同缓存行,可以很好挺高性能~
Fork/Join框架是Java7提供的一个用于并行执行任务的框架,是一个把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架。
Fork/Join框架需要理解两个点,「分而治之」和「工作窃取算法」。
分而治之
以上Fork/Join框架的定义,就是分而治之思想的体现啦
工作窃取算法
把大任务拆分成小任务,放到不同队列执行,交由不同的线程分别执行时。有的线程优先把自己负责的任务执行完了,其他线程还在慢慢悠悠处理自己的任务,这时候为了充分提高效率,就需要工作盗窃算法啦~
工作盗窃算法就是,「某个线程从其他队列中窃取任务进行执行的过程」。一般就是指做得快的线程(盗窃线程)抢慢的线程的任务来做,同时为了减少锁竞争,通常使用双端队列,即快线程和慢线程各在一端。