前言
有感而发
目录
一、基本概念
1、CPU核心数以及线程数的关系
Intel CPU有双核、四核、六核 等等,增加核心数有一点就是为了增加线程数,因为操作系统是通过线程来执行任务的,以前单核CPU下,与线程数的关系是 1:1,现在利用超线程技术,一般是 1:2,例如四核CPU,线程数应该有8个。Android手机架构不一样,有arm、X86,而且八核手机还分四大核、四小核,调度规则也不一致。
2、进程与线程
进程:程序运行资源分配的最小单位
线程:CPU调度的最小单位,依赖于进程
假如**A(微信)**进程有A1、A2线程 , **B(爱奇艺视频)**进程有B1、B2、B3线程,**C(腾讯视频)**进程有C1、C2线程,这些线程都是可执行的。电脑上这三个软件窗口都打开了,CPU大概是如何调度执行这些?
在以前单核CPU单线程状态下,CPU会一个个执行,假设先执行A1线程、再B1线程、C2线程 (执行的线程顺序是不确定的),也就是电脑上你看到的已经打开的 A、B、C三个软件并不是时时刻刻在执行的,CPU会不断调度切换到其它软件去执行相关线程,切换速度很快,所以你能看到三个软件都在正常运行。如下图,一个状态下只执行一个线程,不会同时执行,这就是所谓的CPU轮转机制(RR调度)。
当开启的进程很多、线程很多情况下,例如两万个线程,CPU切换速度势必慢下来(上下文切换耗性能,每次切换都需要把上一个线程数据保存,把当前切换到的线程数据取出来),线程等待时间势必慢下来,这就导致电脑卡死现象原因之一,还和主频等因素也有关系,例如选打游戏电脑指标,你会关注主频。服务器需要稳定,会关注多核心。
3、并行与并发
并行:你和你女朋友同时各拿一个锅,一个开始煎鸡蛋、一个开始煎火腿,同时进行,这就叫做并行。或者是运动会百米冲刺,大家同时开跑。
并发:一般客户端不常会遇到,也可以说基本没有。一般说服务器的吞吐量指的就是并发。例如你的女朋友同时拿拿两个锅,一个煎鸡蛋,一个煎火腿,不断切换注意力到不同的锅上,在一定的时间内完成。或者是五条车道上,一定时间内的车流量,多少车经过。
并发数:是指在同一个时间点,同时请求服务的客户数量。
吞吐量(Throughput) :是指系统在单位时间内处理请求的数量。
二、常见开启线程的方式
1、Thread
继承Thread或者直接 new Thread(),start后,执行完后线程就销毁了。
注意:new Thread 每次一开辟栈空间至少1M(默认栈大小1M + 栈溢出相应的检查16K),一个进程如果有100个线程,那可能就会消耗100M内存。
2、Runnable
自定义Runnable,传递给Thread
3、Callable
private static class WorkerThread implements Callable<String>{
@Override
public String call() throws Exception {
return "Demo Test";
}
}
//开启
WorkerThread workerThread = new WorkerThread();
FutureTask<String> task =new FutureTask<>(workerThread);
new Thread(task).start();
task.get();//取返回值 阻塞
三、线程关闭方式
Thread提供了stop和stopSelf方法,不过都标记了 @Deprecated 过时,不建议使用,例如图片下载,停止线程,图片可能并没有下载成功,stop就会造成一些不避免的错误,还有一些内存释放操作。
现实中,大多数是通过在Thread的run方法里去判断是否需要停止线程,目的就是促使run方法执行完毕。如下
public class MyThread extends Thread {
@Override
public void run() {
//业务逻辑
if(interrupted()){
//直接return
return;
}
}
}
MyThread thread = new MyThread();
thread.start();
//业务
thread.interrupt();//发送中断消息
如果是自定义Runnable,则在run方法中,通过 Thread.currentThread().isInterrupted() 来判断。
这里需要注意一点
try{
Thread.sleep(100);
}catch (InterruptedException e){
}
如果在Thread.sleep期间,执行了 interrupt 方法,将会触发InterruptedException 异常,会清除中断信号。
四、线程的生命周期
图中setDemon是指让线程设置为守护线程,主线程挂了,守护线程也就挂了。
yield 让出执行权,进入到就绪状态(可执行状态)
1、sleep和wait的区别
sleep是Thread的方法,wait是Object的方法,底层都是native方法。
sleep是休眠,等休眠时间一过,才有执行权资格,但是只有又有了资格,不代表马上就能被执行,什么时候执行取决于操作系统的调用。
wait是等待,等待别人来唤醒,唤醒后,才有执行权的资格,和sleep一样,什么时候执行取决于操作系统的调度。
sleep可以无条件马上休眠,对象锁依然保持,而wait的使用,是因为某些条件下才使用的,并且wait会导致当前线程放弃对象的锁,进入对象的等待池。
2、线程是否能被强制中断?
可以,但是不推荐,之前提过,一般通过 interupt 方法来处理,该方法属于协作式停止线程,并不是抢占式。
3、如何控制线程执行顺序?
利用join方法,例如
public static main(String[] args){
....
thread1.start();
thread.join();
thread2.start();
}
程序在main线程中调用thread1的join方法,main线程放弃CPU控制权,返回thread1线程并执行thread1线程完毕,然后CPU执行权再放开。
4、既然可以控制线程执行顺序,那么在Java中能不能指定CPU去执行某个线程?
不能,Java是不能处理的,唯一能够去干预的就是C语言调用内核API去指定。
5、项目开发中,会考虑线程的优先级吗?
一般不会考虑优先级,因为线程的优先级依赖于系统平台,所以这个优先级无法对号入座,所设定的优先级可能是不稳定的,有风险。例如Java线程优先级有十级,android有1000级,操作系统如何只有2-3级,如果是跨平台开发,基本不考虑,如果只是在某个特定端上开发,可以考虑。
五、锁
不考虑
Java并发的CAS乐观锁,悲观锁细节
锁可以分为对象锁、类锁、显示锁,对应 synchronized 和 ReentrantLock
1、Synchronized
public class Test {
private long count = 0;
private Object object = new Object();
private String str = new String();
//对象锁
public void fun() {
synchronized (object) {
count++;
}
}
//对象锁
public synchronized void fun2() {
count++;
}
public void fun3() {
synchronized (this) {
count++;
}
}
//类锁
public static synchronized void fun4(){
}
}
synchronized 可以用在方法、变量、方法上面。
synchronized 标记的代码,编译器自动会生成 monitorenter和monitorexit,执行enter指令尝试获取该对象的锁时会检查锁状态标志,偏向线程ID等类型西,同时利用计数器加1,执行了monitorexit后,计数器减1,计数器为0时自动释放锁。获取对象锁失败,那么当前线程就要阻塞等待,直到对象锁被另外一个线程释放。
为什么锁对象可以?
对象头包含锁,每个对象都持有自己的Monitor锁。
java对象在布局中分为3部分,对象头、实例数据、对其填充。在new创建一个对象时,jvm会在堆中创建一个instanceOopDesc对象,其中由 _mark 和 _metadata 一起组成了对象头, _metadata主要保存类元数据, _mark属性是markOop类型数据,我们都称为mark word标识,存储了hashCode,分代年龄,锁标识,是否是偏向锁等等,然后在32位的JVM中,重量级锁标志位占10位,剩下30位是指向互斥锁的指针Monitor,ObjectMonitor是他的实现,每个对象都有。
说到synchronized,就有必要提一提单例了
public class Singleton {
private static Singleton singleton;
private Singleton() {
}
public static synchronized Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
上面这种写法是线程安全的,唯一不足是每次只能有一个线程访问,会导致一定的性能损失
public class Singleton {
private volatile static Singleton singleton;
private Singleton() {
}
public static Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class){
if(singleton == null){
singleton = new Singleton(); // 1
}
}
}
return singleton;
}
}
上面这种事DCL线程安全写法。假如没有使用volatile关键字,会造成什么问题呢?
首先我们直到创建一个对象的过程
-
分配内存空间
-
初始化对象
-
将对象空间的地址赋值给引用
如果没有使用volatile关键字,在线程A执行到 1 处的方法时,由于指令重排的原因,会出现执行了创建对象的第一步、第三步,此时singleton确实不会为空,并且这时候另一个线程执行到getInstance方法,就直接返回了,而此时对象的构造方法并没有被调用,会出现第二个线程拿到的数据是错乱的。
加了volatile关键字,避免了JVM对其的指令重排,完全按创建对象的顺序来,就不会出现此类问题,当然你也可以通过内部类的形式来创建单例
public class Singleton {
private volatile static Singleton singleton;
private Singleton() {
}
public static Singleton getInstance() {
return InnerHolder.S_INSTANCE;
}
private static class InnerHolder{
private static final Singleton S_INSTANCE = new Singleton();
}
}
首次加载Singleton类时并不会初始化Instance,只有在第一次调用getInstance()时会导致虚拟机加载SingleHolder类。
虚拟机会保证一个类的构造器<clint>方法在多线程环境中被正确加载,同步,如果多个线程同时去初始化一个类,那么只有一个类去执行,其他线程都需要阻塞等待,直到活动线程<clint>方法完毕,所以通过内部类创建单例是线程安全的。
2、ReentrantLock
try{
lock.lock();
//业务逻辑
....
}finally{
lock.unlock();
}
ReentrantLock和读写锁ReentrantReadWriteLock,它们都实现Lock接口
ReentrantLock的构造函数
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
fair为 true代表公平锁,false代表非公平锁,默认是false
那么非公平和公平有什么区别呢?
如果使用公平锁,如果多个线程访问同一个方法,先申请访问的会先执行,其它的线程挂起,例如第一申请的执行完该方法后,就会上下文切换,轮到第二申请的线程执行该方法了。如果使用的是非公平锁,不会管申请的先后顺序,第一申请的执行完该方法后,将由CPU调度,决定哪个线程执行。从源码角度来说,公平锁,是会判断队列中是否还有其他线程在等待尝试获取锁,会按队列的先后顺序来获取锁(线程上下文切换),非公平锁,是直接尝试获取锁的。
所以公平锁相对非公平锁会耗时间、耗性能,非公平锁的效率更高。
3、可重入锁
public class LockTest {
private static int count= 0;
public static synchronized void incrs(){
System.out.println("count -> "+count);
count ++;
if(count < 3) {
incrs();
//注释 1
int copy = count;
System.out.println("copy -> "+copy);
}
}
public static void main(String[] args) {
incrs();
}
}
这段代码结果是
count -> 0
count -> 1
count -> 2
copy -> 3
copy -> 3
可以看出 synchronized 是可重入锁,并且 注释 1 处代码只在最后执行一遍。如果不是可重入锁,那么将会导致死锁。ReentrantLock 也是可重入锁。
4、synchronized 的缺点
不支持中断和超时,也就是说通过synchronized一旦被阻塞住,如果一直无法获取到所资源就会一直被阻塞,即使中断也没用,这对并发系统的性能影响太大了
Lock支持中断和超时、还支持尝试机制获取锁,对synchronized进行了很好的扩展
5、notify 和 notifyAll 选哪个
public class LockTest {
static class Product {
private String name;
private boolean isCreated;
public synchronized void put(String name) {
if(!isCreated) {
isCreated = true;
this.name = name;
System.out.println("生产 -> " + name);
this.notify();
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public synchronized void get() {
if(isCreated) {
System.out.println("消费 -> " + name);
isCreated = false;
notify();
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
static class ProductRunnable implements Runnable {
Product product;
public ProductRunnable(Product product) {
this.product = product;
}
@Override
public void run() {
for (int i = 0; i < 4; i++) {
product.put("Product " + i);
}
}
}
static class ConsumeRunnable implements Runnable {
Product product;
public ConsumeRunnable(Product product) {
this.product = product;
}
@Override
public void run() {
for (int i = 0; i < 4; i++) {
product.get();
}
}
}
public static void main(String[] args) {
Product product = new Product();
ProductRunnable productRunnable = new ProductRunnable(product);
ConsumeRunnable consumeRunnable = new ConsumeRunnable(product);
new Thread(productRunnable).start();
new Thread(consumeRunnable).start();
}
}
上面是一个简易版的消费者和生产者
输出
生产 -> Product 0
消费 -> Product 0
生产 -> Product 1
消费 -> Product 1
生产 -> Product 2
消费 -> Product 2
生产 -> Product 3
消费 -> Product 3
-
wait()等待/冻结 :可以将线程冻结,释放CPU执行资格,释放CPU执行权,并把此线程临时存储到线程池,此时对象锁释放了,notify可以获取到对象锁 -
notify()唤醒线程池里面 任意一个线程,没有顺序; -
notifyAll();唤醒线程池里面,全部的线程; -
注意:
wait()notify()这些必须要有同步锁包裹着
到这里,大概也能理解了为什么大多数开源框架内都是使用的notifyAll了吧,notify 只是把CPU执行权释放了,具体哪一个线程被唤醒也不确定,除非开发者非常清楚,否则使用notifyAll 全部唤醒,避免想要唤醒的线程没有被唤醒导致的一系列问题
6、死锁、活锁
死锁: 两个或者两个以上的进程(线程)在执行的过程中,由于警政资源或者由于彼此通信而造成的阻塞现象。
满足条件就是去竞争锁的线程有两个或者两个以上,而资源数小于等于竞争锁的线程。就比如只有一个苹果和一个梨,A拿了一个苹果,B拿了一个梨,A又想要B的梨子,B也还想要A的苹果,它两都不肯把自己原有的水果送出去。这就造成了死锁,这时候再来一个C,更加是死锁了。
具体来说,有以下四个必要条件:
-
互斥条件:一段时间内,某个资源只由一个线程占用,如果此时其它线程请求资源,只能等待,除非该资源被释放
-
请求和保持条件:某个进程之前申请了资源,我还想再申请资源,之前的资源还是我占用着,别人别想动。除非我自己不想用了,释放掉。
-
不可剥夺田间:某个进程占用了资源,就只能他自己去释放
-
循环等待条件:A等待B的资源,B等待C的资源,C等待A的资源
死锁产生后,整个程序虽然说是活的,业务逻辑势必会阻塞,重启也没用,问题很严重。平时使用时注意拿锁的顺序或者是通过ReentrantLock的trylock去拿锁。
活锁:两个线程在尝试拿锁的机制中,发生多个线程之间互相谦让,不断发生同一个线程总是拿到同一把锁,在尝试拿另一把锁时因为拿不到,而将本来已经持有的锁释放的过程。
就比如A拿到了锁1,B拿到了锁2,然后A尝试去拿锁2时,B也去尝试拿锁1,拿不到,A就释放锁1,B就释放锁2,然后又如此重复。
看到知乎上有这么解释的
活锁就是 马路中间有条小桥,只能容纳一辆车经过,桥两头开来两辆车A和B,A比较礼貌,示意B先过,B也比较礼貌,示意A先过,结果两人一直谦让谁也过不去。
死锁如果是两个不动的齿轮,活锁大概就是你低头走在路上,正好快要碰上一个美女,你马上往左边垮了一步,但美女也正好往右边跨了一步,不断循环,最后谁都过不去。
两者的区别就在于死锁,两个线程都是处于阻塞状态,活锁并不会阻塞,而是一直尝试去获取需要的锁。虽然死锁和活锁在表现上都是一样的两个线程没有任何进展。
7、线程饥饿
低优先级的线程,总是拿不到执行时间
线程A占用了资源1,线程B请求获取资源1,然后线程B在等待,线程C也请求获取资源1,线程A释放了资源,系统CPU调度执行了线程C获取资源的请求,此时线程B还是在等待。然后又来一个线程D,当C释放资源时,执行权给了线程D。然后线程B就一直在等待获取资源1。所以在使用锁的时候一般会默认使用非公平锁。