基础概念
进程和线程
进程是程序运行资源分配的最小单位
线程是CPU调度的最小单位,必须依赖于进程二存在
任何程序都必须创建线程,Java程序的main函数就会创建一个主线程
Linux下一个进程最多只能开1000个线程,新线程分配栈空间1M
CPU核心数和线程数关系
多线程: Simultaneous Multithreading,简称SMT,让同一个处理器上的多个线程同步执行并共享处理器的执行资源。
核心数、线程数:目前主流CPU都是多核的。增加核心数目就是为了增加线程数,因为操作系统是通过线程来执行任务的,一般情况下它们是1:1对应关系,也就是说四核CPU一般拥有四个线程。但Intel引入超线程技术后,使核心数与线程数形成1:2的关系
CPU时间片轮转机制
我们平时在开发的时候,感觉并没有受cpu核心数的限制,想启动线程就启动线程,哪怕是在单核CPU上,为什么?这是因为操作系统提供了一种CPU时间片轮转机制
时间片轮转调度是一种最古老、最简单、最公平且使用最广的算法,又称RR调度。每个进程被分配一个时间段,称作它的时间片,即该进程允许运行的时间
并行和并发
Concurrent vs Parallel
并发是指任务提交,并行是指任务执行
并发是在一定时间内交替执行,并行是同一时间共同执行
线程的启动和终止
线程的启动
启动线程的方式有:
X extends Thread,然后X.start
X implements Runnable,然后交给Thread运行new Thread(new X()).start()
如果非要说有三种:a) Thread b) Runable c)线程池(或者Lambda表达式)
Thread和Runnable的区别
Thread才是Java里对线程的唯一抽象
Runnable只是对任务(业务逻辑)的抽象。Thread可以接受任意一个Runnable的实例并执行。
线程的终止
stop()还是interrupt() 、 isInterrupted()、static方法interrupted(),深入理解这些方法
stop()立即终断线程
interrrupt()给线程打了一个终断标志位,线程不是立即终止
如果一个线程处于了阻塞状态(如线程调用了thread.sleep、thread.join、thread.wait等),则在线程在检查中断标示时如果发现中断标示为true,则会在这些阻塞方法调用处抛出InterruptedException异常,并且在抛出异常后会立即将线程的中断标示位清除,即重新设置为false
不建议自定义一个取消标志位来中止线程的运行
因为run方法里有阻塞调用时会无法很快检测到取消标志,线程必须从阻塞调用返回后,才会检查这个取消标志。这种情况下,使用中断会更好,因为
一般的阻塞方法,如sleep等本身就支持中断的检查,
检查中断位的状态和检查取消标志位没什么区别,用中断位的状态还可以避免声明取消标志位,减少资源的消耗。
注意:处于死锁状态的线程无法被中断
线程的状态
可以用Thread.getState()获取线程的状态
public class T04_ThreadState {
static class MyThread extends Thread {
@Override
public void run() {
System.out.println(this.getState());
for(int i=0; i<5; i++) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(this.getState());
System.out.println(i);
}
}
}
public static void main(String[] args) {
Thread t = new MyThread();
System.out.println(t.getState());
t.start();
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(t.getState());
}
}
运行结果:
---------
NEW
RUNNABLE
RUNNABLE
0
RUNNABLE
1
RUNNABLE
2
RUNNABLE
3
RUNNABLE
4
TERMINATED
----------
线程的run()和start()
Thread类是Java里对线程概念的抽象,可以这样理解:我们通过new Thread()其实只是new出一个Thread的实例,还没有操作系统中真正的线程挂起钩来。只有执行了start()方法后,才实现了真正意义上的启动线程
start()方法让一个线程进入就绪队列等待分配cpu,分到cpu后才调用实现的run()方法,start()方法不能重复调用,如果重复调用会抛出异常
run()方法是业务逻辑实现的地方,本质上和任意一个类的任意一个成员方法并没有任何区别,可以重复执行,也可以被单独调用
其他的线程相关方法
yield方法
yield()方法:使当前线程让出CPU占有权,但让出的时间是不可设定的。也不会释放锁资源
注意:并不是每个线程都需要这个锁的,而且执行yield( )的线程不一定就会持有锁,我们完全可以在释放锁后再调用yield方法。
所有执行yield()的线程有可能在进入到就绪状态后会被操作系统再次选中马上又被执行
wait()/notify()/notifyAll():后面会单独讲述
join方法
把指定的线程加入到当前线程,可以将两个交替执行的线程合并为顺序执行。比如在线程B中调用了线程A的Join()方法,直到线程A执行完毕后,才会继续执行线程B(此处为常见面试考点)
总结:线程常用方法 join(不释放锁)、yield(不释放锁)、sleep(不释放锁)、wait(释放锁)、notify/notifyAll(不释放锁)
线程的优先级
在Java线程中,通过一个整型成员变量priority来控制优先级,优先级的范围从1~10,在线程构建的时候可以通过setPriority(int)方法来修改优先级,默认优先级是5,优先级高的线程分配时间片的数量要多于优先级低的线程。
设置线程优先级时,针对频繁阻塞(休眠或者I/O操作)的线程需要设置较高优先级,而偏重计算(需要较多CPU时间或者偏运算)的线程则设置较低的优先级,确保处理器不会被独占。在不同的JVM以及操作系统上,线程规划会存在差异,有些操作系统甚至会忽略对线程优先级的设定。
守护线程
Daemon(守护)线程是一种支持型线程,因为它主要被用作程序中后台调度以及支持性工作。这意味着,当一个Java虚拟机中不存在非Daemon线程的时候,Java虚拟机将会退出。可以通过调用Thread.setDaemon(true)将线程设置为Daemon线程。我们一般用不上,比如垃圾回收线程就是Daemon线程
Daemon线程被用作完成支持性工作,但是在Java虚拟机退出时Daemon线程中的finally块并不一定会执行。在构建Daemon线程时,不能依靠finally块中的内容来确保执行关闭或清理资源的逻辑
线程间的共享
线程的共享synchronized内置锁
线程开始运行,拥有自己的栈空间,如果多个线程能够相互配合完成工作,包括数据之间的共享,协同处理事情。这将会带来巨大的价值
Java支持多个线程同时访问一个对象或者对象的成员变量,关键字synchronized可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和排他性,又称为内置锁机制
线程的锁synchronized,锁的是对象不是代码段
synchronized(this) 和 synchronized 加在方法上是相同的意思
在静态方法上加锁等于在这个类的对象上加锁
class T {
//等同于synchronized(T.class),锁加在类对象上面了
public synchronized static void function(){}
}
锁定方法和非锁定方法可以同时执行
synchronized 方法是可重入的,两个方法同一把锁,如果拿到锁调用func1,可以在func1中再调用func2,否则会死锁,同理父类和子类之间的相同 synchronized 方法也必须是可重入的
注意:
synchronized 获取锁后程序执行异常,默认会释放锁,导致其他锁争夺线程乱入,是非常危险的
synchronized(Object)
不要用String常量 Integer Long这些基础数据类型,因为++或者是改变String值其实是让原对象指向了一个新的对象,导致不同线程加锁的对象也不同,不能实现正常锁功能
把synchronized的对象定义为final,以防止其被指向其他实例对象
面试题:模拟银行账户、对业务写方法加锁,对业务读数据不加锁,这样行不行?(业务是否允许脏读)
对象锁和类锁
对象锁是用于对象实例方法,或者一个对象实例上的,类锁是用于类的静态方法或者一个类的class对象上的。我们知道,类的对象实例可以有很多个,但是每个类只有一个class对象,所以不同对象实例的对象锁是互不干扰的,但是每个类只有一个类锁
注意:
其实类锁只是一个概念上的东西,并不是真实存在的,类锁其实锁的是每个类的对应的class对象。类锁和对象锁之间也是互不干扰的
synchronized的字节码指令
通过javap -v 来查看对应代码的字节码指令,对于同步块的实现使用了monitorenter和monitorexit指令,他们隐式的执行了Lock和UnLock操作,用于提供原子性保证。 monitorenter指令插入到同步代码块开始的位置、monitorexit指令插入到同步代码块结束位置,jvm需要保证每个monitorenter都有一个monitorexit对应
对象在内存中的布局
在 Hotspot 虚拟机中,对象在内存中的存储布局,可以分为三个区域:
对象头(Header):对象头包括MarkWord和类型指针
实例数据(Instance Data):对象的成员变量
对齐填充(Padding):填充至8Byte的倍数
Java对象头是实现 synchronized的锁对象的基础。一般而言,synchronized使用的锁对象是存储在Java对象头里。它是轻量级锁和偏向锁的关键
Mark Word
Mark Word(对象标记)用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节, 也就是32bit)
当一个对象已经计算过identity hash code,他就无法进入偏向锁状态
synchronized底层实现锁升级
无锁->偏向锁->轻量级锁(自旋锁)->重量级锁
偏向锁:大多数情况下,锁总是由同一个线程多次获得。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,偏向锁是一个可重入的锁。如果锁对象头的Mark Word里存储着指向当前线程的偏向锁,无需重新进行CAS操作来加锁和解锁。当有其他线程尝试竞争偏向锁时,持有偏向锁的线程(不处于活动状态)才会释放锁。偏向锁无法使用自旋锁优化,因为一旦有其他线程申请锁,就破坏了偏向锁的假定进而升级为轻量级锁
轻量级锁:减少无实际竞争情况下,使用重量级锁产生的性能消耗。JVM会现在当前线程的栈桢中创建用于存储锁记录的空间 LockRecord,将对象头中的 Mark Word 复制到 LockRecord 中并将 LockRecord 中的 Owner 指针指向锁对象。然后线程会尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针,成功则当前线程获取到锁,失败则表示其他线程竞争锁当前线程则尝试使用自旋的方式获取锁。自旋达到一定次数获取锁失败则锁膨胀升级为重量级锁
自旋锁(CAS):让不满足条件的线程等待一会看能不能获得锁,通过占用处理器的时间来避免线程切换带来的开销。自旋等待的时间或次数是有一个限度的,如果自旋超过了定义的时间仍然没有获取到锁,则该线程应该被挂起。在 JDK1.6 之后,引入了自适应自旋锁,自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源
重量级锁:通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实 现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。线程竞争不使用自旋,不会消耗CPU。但是线程会进入阻塞等待被其他线程被唤醒,响应时间缓慢
锁只会升级,不会降级
自旋锁:占CPU,但是不访问操作系统,在用户态解决锁的争抢和释放,不经过内核态执行时间短(加锁代码),等待线程少用自旋锁
OS锁:执行时间比较长,线程比较多用OS锁
面试题
调用yield() 、sleep()、wait()、notify()等方法对锁有何影响?
yield() 、sleep()被调用后,都不会释放当前线程所持有的锁
调用wait()方法后,会释放当前线程持有的锁,而且当前被唤醒后,会重新去竞争锁,锁竞争到后才会执行wait方法后面的代码
调用notify()系列方法后,对锁无影响,线程只有在syn同步代码执行完后才会自然而然的释放锁,所以notify()系列方法一般都是syn同步代码的最后一行