目录
一、进程与线程
- 进程:运行中的程序,比如王者荣耀、腾讯视频。
- 线程:任务。
1.区别:
- 本质区别:进程是操作系统资源分配的基本单位,而线程是任务调度和程序执行的基本单位。
- 地址空间:同一进程的线程共享本进程的地址空间,而进程之间则是独立的地址空间。
- 资源拥有:同一进程内的线程共享本进程的资源如内存、I/O、cpu等,但是进程之间的资源是独立的。
- 执行过程:每个独立的进程程有一个程序运行的入口、顺序执行序列和程序入口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。
- 执行开销:线程执行开销小,进程执行开销大。
- 健壮性:但是多进程程序更健壮,多线程程序只要有一个线程死掉,整个进程也死掉了,而一个进程死掉并不会对另外一个进程造成影响,因为进程有自己独立的地址空间。
- 通信方式:父和子进程使用进程间通信机制,同一进程的线程通过读取和写入数据到进程变量来通信。
2.进程间通信方式:管道、消息队列、共享内存、信号、信号量、套接字。
3.线程间通信方式:volatile、同步、wait()/notify()。
二、创建多线程的三种方式及区别
- 继承 Thread类:
- 实现 Runnable接口:只有一个抽象方法 run( )方法;允许多继承。
- 实现 Callable接口:只有一个call() 方法;有返回值,通过 FutureTask 进行封装;call( )方法允许抛出异常。
Runnable、Callable接口的实现类的作用:作为真正线程对象的 target,定义一个线程的执行体。而真正的线程对象是new Thread(target) 。
public class MyCallable implements Callable<Integer> {
public Integer call() {
return 123;
}
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
MyCallable mc = new MyCallable();
FutureTask<Integer> ft = new FutureTask<>(mc);
Thread thread = new Thread(ft);
thread.start();
System.out.println(ft.get());
}
实现接口 VS 继承 Thread:
实现接口会更好一些,因为:
- Java 不支持多重继承,因此继承了 Thread 类就无法继承其它类,但是可以实现多个接口;
- 接口的实现类,只是作为一个Thread对象的 target参数,因此可以新建多个线程共享这一个target。所以可以建多个线程共享实现类的全局变量。
1.线程的五种状态:新建、就绪、运行、阻塞、结束。
2.sleep、yield、join区别:
- 休眠线程(sleep):不释放锁,不会让出系统资源;指定时间后进入就绪状态,不一定立即被执行
- (wait):释放锁,让出系统资源;唤醒后进入就绪状态,不一定被立即执行
- 加入线程(join):当前线程等待,调用join()方法的线程结束后才能继续运行
- 礼让线程(yield):不释放锁;立即回到就绪状态,使相同/更高优先级线程得到执行机会,也可能立即被执行
三、synchronized
1.作用:多个线程访问同一个数据,容易出现线程安全问题。所以使用synchronized,阻止两个线程对同一个共享资源并发访问。
2.锁的是什么?
- 同步代码块:锁括号里类的 实例对象或 类对象(xx.class)
- 同步普通方法:锁当前类的实例对象
- 同步静态方法:锁当前类的类对象
3.线程在什么情况下释放锁:
- 同步方法、同步代码块执行结束。
- 同步方法、同步代码块中遇到break、return。
- 同步方法、同步代码块中出现未处理的Error或Exception。(异常结束)
- 同步方法、同步代码块中,执行了wait()方法,线程暂停。
4.线程不会释放锁的情况:
- 同步方法、同步代码块中,程序调用 Thread.sleep( )、Thread.yield( )方法来暂停当前线程的执行。
5.实现原理
(1)synchronized:每一个对象都有一个监视器(monitor),即锁,存储在对象的对象头的Mark Word里,线程获取对象的monitor,没有获取成功进入阻塞队列,
- 同步代码块:执行 monitorenter指令获得monitor、计数器加1,monitorexit指令、减1。
- 同步方法:JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。
(2)ReentrantLock:重入锁,
//1.ReentrantLock
public class ReentrantLock implements Lock {}
//2.ReentrantLock 的内部类 Sync
abstract static class Sync extends AbstractQueuedSynchronizer {}
//3.非公平模式下加锁
static final class NonfairSync extends Sync {}
//4.公平锁模式下加锁
static final class FairSync extends Sync {}
6.synchronized 和 ReentrantLock 两种锁机制的比较:
- 相同:都是独占锁,都是可重入的,都可实现线程间等待唤醒(wait / await、notify / signal)
- 不同:实现 + 是否自动释放 + 性能 + 可响应中断 + 公平锁 + 限时等待获取锁 + 绑定对象个数
性能:
- synchronized:属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。
- ReentrantLock:
7.使用选择:
除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized。这是因为 synchronized 是 JVM 实现的一种锁机制,JVM 原生地支持它,而 ReentrantLock 不是所有的 JDK 版本都支持。并且使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放。
- volatile 保证了操作的可见性,不保证原子性
- synchronized 既能保证可见性,又能保证原子性
四、锁分类及优化
(一)乐观锁
总是认为不会产生并发问题,每次去取数据的时候总认为不会有其他线程对数据进行修改,因此不会上锁,但是在更新时会判断其他线程在这之前有没有对数据进行修改,一般会使用版本号机制或CAS操作实现。
- version方式:一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。
核心SQL代码:
update table set x=x+1, version=version+1 where id=#{id} and version=#{version};
- CAS操作方式:即compare and swap 或者 compare and set,涉及到三个操作数,数据所在的内存值,预期值,新值。当需要更新时,判断当前内存值与之前取到的值是否相等,若相等,则用新值更新,若失败则重试,一般情况下是一个自旋操作,即不断的重试。
(二)悲观锁
总是假设最坏的情况,每次取数据时都认为其他线程会修改,所以都会加锁(读锁、写锁、行锁等),当其他线程想要访问数据时,都需要阻塞挂起。可以依靠数据库实现,如行锁、读锁和写锁等,都是在操作之前加锁,在Java中,synchronized的思想也是悲观锁。
(三)自旋锁
互斥同步进入阻塞状态的开销都很大,应该尽量避免。在许多应用中,共享数据的锁定状态只会持续很短的一段时间。自旋锁的思想是让一个线程在请求一个共享数据的锁时执行忙循环(自旋)一段时间,如果在这段时间内能获得锁,就可以避免进入阻塞状态。
自旋锁虽然能避免进入阻塞状态从而减少开销,但是它需要进行忙循环操作占用 CPU 时间,它只适用于共享数据的锁定状态很短的场景。
在 JDK 1.6 中引入了自适应的自旋锁。自适应意味着自旋的次数不再固定了,而是由前一次在同一个锁上的自旋次数及锁的拥有者的状态来决定。
(四)锁消除
锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除。
锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除。
对于一些看起来没有加锁的代码,其实隐式的加了很多锁。例如下面的字符串拼接代码就隐式加了锁:
public static String concatString(String s1, String s2, String s3) {
return s1 + s2 + s3;
}
String 是一个不可变的类,编译器会对 String 的拼接自动优化。在 JDK 1.5 之前,会转化为 StringBuffer 对象的连续 append() 操作:
public static String concatString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
每个 append() 方法中都有一个同步块。虚拟机观察变量 sb,很快就会发现它的动态作用域被限制在 concatString() 方法内部。也就是说,sb 的所有引用永远不会逃逸到 concatString() 方法之外,其他线程无法访问到它,因此可以进行消除。
(五)锁粗化
如果一系列的连续操作都对同一个对象反复加锁和解锁,频繁的加锁操作就会导致性能损耗。
上一节的示例代码中连续的 append() 方法就属于这类情况。如果虚拟机探测到由这样的一串零碎的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部。对于上一节的示例代码就是扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,这样只需要加锁一次就可以了。
(六)偏向锁
偏向锁的思想是偏向于让第一个获取锁对象的线程,这个线程在之后获取该锁就不再需要进行同步操作,甚至连 CAS 操作也不再需要。
当锁对象第一次被线程获得的时候,进入偏向状态,标记为 1 01。同时使用 CAS 操作将线程 ID 记录到 Mark Word 中,如果 CAS 操作成功,这个线程以后每次进入这个锁相关的同步块就不需要再进行任何同步操作。
当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向(Revoke Bias)后恢复到未锁定状态或者轻量级锁状态。
(七)轻量级锁
(八)重量级锁
五、线程池
1.什么是线程池?
创建、管理线程,给任务分配线程
2.为什么要使用线程池?
创建线程和销毁线程的花销是比较大的,这些时间有可能比处理业务的时间还要长。这样频繁的创建线程和销毁线程,再加上业务工作线程,消耗系统资源的时间,可能导致系统资源不足。
3.线程池有什么作用?
线程池作用就是限制系统中执行线程的数量。
- 提高效率 创建好一定数量的线程放在池中,等需要使用的时候就从池中拿一个,这要比需要的时候创建一个线程对象要快的多。
- 方便管理 可以编写线程池管理代码对池中的线程同一进行管理,比如说启动时有该程序创建100个线程,每当有请求的时候,就分配一个线程去工作,如果刚好并发有101个请求,那多出的这一个请求可以排队等候,避免因无休止的创建线程导致系统崩溃。
//线程池相关的类、接口
public interface Executor {}
public interface ExecutorService extends Executor {}
public abstract class AbstractExecutorService implements ExecutorService {}
public class ThreadPoolExecutor extends AbstractExecutorService {}
4.说说几种常见的线程池及使用场景
- newSingleThreadExecutor
创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。
- newFixedThreadPool
创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
- newCachedThreadPool
创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
- newScheduledThreadPool
创建一个定长线程池,支持定时及周期性任务执行。
5.线程池中的几种重要的参数
- corePoolSize:核心线程数。这几个核心线程,只是在没有用的时候,也不会被回收
- maximumPoolSize:最大线程数。
- keepAliveTime:非核心线程最长空闲时间。就是线程池中除了核心线程之外的其他的最长可以保留的时间,因为在线程池中,除了核心线程即使在无任务的情况下也不能被清除,其余的都是有存活时间的
- util:keepAliveTime的时间单位。
- workQueue:阻塞队列。用来存储等待执行的任务,执行的是FIFIO原则(先进先出)。
- threadFactory:线程的创建方式。
- handler:拒绝策略。我们可以在任务满了之后,拒绝执行某些任务。
6.说说线程池的拒绝策略
当请求任务不断的过来,而系统此时又处理不过来的时候,我们需要采取的策略是拒绝服务。RejectedExecutionHandler接口提供了拒绝任务处理的自定义方法的机会。在ThreadPoolExecutor中已经包含四种处理策略。
- AbortPolicy:拒绝任务并抛出异常。
- CallerRunsPolicy:调用线程运行当前任务。
- DiscardOleddestPolicy: 丢弃最老的任务,加入新提交的任务。
- DiscardPolicy:直接拒绝任务,不抛出异常。
除了JDK默认提供的四种拒绝策略,我们可以根据自己的业务需求去自定义拒绝策略,自定义的方式很简单,直接实现RejectedExecutionHandler接口即可。
7.execute和submit的区别?
在前面的讲解中,我们执行任务是用的execute方法,除了execute方法,还有一个submit方法也可以执行我们提交的任务。
这两个方法有什么区别呢?分别适用于在什么场景下呢?我们来做一个简单的分析。
- execute适用于不需要关注返回值的场景,只需要将线程丢到线程池中去执行就可以了。
- submit方法适用于需要关注返回值的场景
8.五种线程池的使用场景
- newSingleThreadExecutor:一个单线程的线程池,可以用于需要保证顺序执行的场景,并且只有一个线程在执行。
- newFixedThreadPool:一个固定大小的线程池,可以用于已知并发压力的情况下,对线程数做限制。
- newCachedThreadPool:一个可以无限扩大的线程池,比较适合处理执行时间比较小的任务。
- newScheduledThreadPool:可以延时启动,定时启动的线程池,适用于需要多个后台线程执行周期任务的场景。
- newWorkStealingPool:一个拥有多个任务队列的线程池,可以减少连接数,创建当前可用cpu数量的线程来并行执行。
9.线程池的关闭
关闭线程池可以调用shutdownNow和shutdown两个方法来实现
- shutdownNow:对正在执行的任务全部发出interrupt(),停止执行,对还未开始执行的任务全部取消,并且返回还没开始的任务列表。
- shutdown:当我们调用shutdown后,线程池将不再接受新的任务,但也不会去强制终止已经提交或者正在执行中的任务。
10.初始化线程池时线程数的选择
- 如果任务是IO密集型,一般线程数需要设置2倍CPU数以上,以此来尽量利用CPU资源。
- 如果任务是CPU密集型,一般线程数量只需要设置CPU数加1即可,更多的线程数也只能增加上下文切换,不能增加CPU利用率。
上述只是一个基本思想,如果真的需要精确的控制,还是需要上线以后观察线程池中线程数量跟队列的情况来定。
11.线程池都有哪几种工作队列
- ArrayBlockingQueue
是一个基于数组结构的有界阻塞队列,此队列按 FIFO(先进先出)原则对元素进行排序。
- LinkedBlockingQueue
一个基于链表结构的阻塞队列,此队列按FIFO (先进先出) 排序元素,吞吐量通常要高于ArrayBlockingQueue。静态工厂方法Executors.newFixedThreadPool()使用了这个队列
- SynchronousQueue
一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQueue,静态工厂方法Executors.newCachedThreadPool使用了这个队列。
- PriorityBlockingQueue
一个具有优先级的无限阻塞队列。
六、AQS
七、线程安全
不可变
互斥同步
非阻塞同步
无同步方案
多线程实现售票系统
public class Main {
public static void main(String[] args){
Sale sale = new Sale();
Thread t1 = new Thread(sale);
Thread t2 = new Thread(sale);
t1.start();
t2.start();
}
}
class Sale implements Runnable{
int count = 100;
@Override
public void run() {
while(true) {
synchronized (this) {
if(count > 0) {
System.out.println(Thread.currentThread().getName()+ "线程在执行,当前票数为:" + count--);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}else {
System.out.println("当前票数不足,不足,不足。。。");
break;
}
}
}
}
}
使用 BlockingQueue 实现生产者消费者问题
public class ProducerConsumer {
private static BlockingQueue<String> queue = new ArrayBlockingQueue<>(5);
private static class Producer extends Thread {
@Override
public void run() {
try {
queue.put("product");
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.print("produce..");
}
}
private static class Consumer extends Thread {
@Override
public void run() {
try {
String product = queue.take();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.print("consume..");
}
}
}
public static void main(String[] args) {
for (int i = 0; i < 2; i++) {
Producer producer = new Producer();
producer.start();
}
for (int i = 0; i < 5; i++) {
Consumer consumer = new Consumer();
consumer.start();
}
for (int i = 0; i < 3; i++) {
Producer producer = new Producer();
producer.start();
}
}
(二)生产者、消费者
public class Main {
static int ticket = 0;
static int produce = 0;
static int sale = 0;
public static void main(String[] args){
Producer p = new Producer();
p.start();
Consumer c = new Consumer();
for(int i = 0; i < 6; i++) {
Thread t = new Thread(c);
t.start();
}
}
private static class Producer extends Thread{
@Override
public void run() {
while(true) {
synchronized (this) {
if(ticket >= 100) {
try {
System.out.println("票数太多了,不能再生产了。。。");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}else {
System.out.println("生产者:这是我生产的第:" + ++produce + "张票,还剩" + ++ticket + "张票。");
}
}
}
}
}
private static class Consumer implements Runnable{
@Override
public void run() {
while(true) {
synchronized (this) {
if(ticket > 0) {
System.out.println(Thread.currentThread().getName() + "消费者:这是我卖出的第:" + ++sale + "张票,还剩" + --ticket + "张票。");
}else {
System.out.println("当前票数为零。。。");
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
}
}
如有错误,欢迎留言指正 * _ *