1 什么是进程?什么是线程?
进程(process) 是操作系统中一个正在运行的程序的实例。每个进程都有自己的内存空间(包括代码、数据和堆栈)、资源和状态。进程之间相互独立,通常由操作系统负责管理和调度,可以同时运行多个进程,每个进程之间互相隔离。
线程(thread) 是进程内的一个独立执行单元。一个进程可以包含多个线程,它们共享相同的内存空间和资源。线程在同一个进程内并发执行,能够更高效地利用系统资源,例如多核处理器的并行计算
1.1 进程和线程的区别和联系
- 进程包含线程,都是为了实现 “并发编程” 的方式.线程比进程更轻量
- 进程是系统分配资源的基本单位,而线程是系统调度执行的基本单位. 创建进程 的时候把分配资源的工作给做了,后续创建线程,直接共用之前的资源即可
- 进程有独立的地址空间、彼此之间不会相互影响到,进程的独立性,让系统的稳定性得到提升,多个线程共用一份地址空间,一个线程一旦抛出异常,就可能会导致整个进程异常结束,也就意味着多个线程之间容易相互影响
总结来说,进程是操作系统分配资源的基本单位,而线程是操作系统进行调度的基本单位。进程之间相互独立,,而线程是共享所属进程的资源。
2 线程的状态转变原理
![](https://i-blog.csdnimg.cn/direct/abbcca05a76c455ab0ffbe73170d8966.png)
2.1 线程的状态
1. 新建状态(New):用new语句创建的线程处于新建状态,此时它和其他Java对象一样,仅仅在堆区中被分配了内存。
2. 就绪状态(Runnable):当一个线程对象创建后,其他线程调用它的start()方法,该线程就进入就绪状态,Java虚拟机会为它创建方法调用栈和程序计数器。处于这个状态的线程位于可运行池中,等待获得CPU的使用权。
3. 运行状态(Running):处于这个状态的线程占用CPU,执行程序代码。只有处于就绪状态的线程才有机会转到运行状态。
4.阻塞状态(Blocked):阻塞状态是指线程因为某些原因放弃CPU,暂时停止运行。当线程处于阻塞状态时,Java虚拟机不会给线程分配CPU。直到线程重新进入就绪状态,它才有机会转到运行状态。阻塞状态可分为以下3种:
- 位于对象等待池中的阻塞状态(Blocked in object’s wait pool):当线程处于运行状态时,如果执行了某个对象的wait()方法,Java虚拟机就会把线程放到这个对象的等待池中,这涉及到“线程通信”的内容。
- 位于对象锁池中的阻塞状态(Blocked in object’s lock pool):当线程处于运行状态时,试图获得某个对象的同步锁时,如果该对象的同步锁已经被其他线程占用,Java虚拟机就会把这个线程放到这个对象的锁池中,这涉及到“线程同步”的内容。
- 其他阻塞状态(Otherwise Blocked):当前线程执行了sleep()方法,或者调用了其他线程的join()方法,或者发出了I/O请求时,就会进入这个状态。
5. 终止状态(Terminated): 线程直到执行结束或执行过程中因异常意外终止都会使线程进入终止状态。
2.2 线程状态转换原理
- 新建到就绪:调用 start() 方法使得线程进入可运行状态,等待系统调度。
- 就绪到运行:当线程被调度器选中,并分配了CPU时间片,开始执行线程的 run() 方法。
- 运行到阻塞/等待/超时等待:调用 sleep()、wait()、join() 等方法,或者等待获取锁等原因导致线程放弃CPU资源,进入相应的阻塞、等待或超时等待状态。
- 阻塞/等待/超时等待到可运行:当线程等待的条件满足(比如获取了锁、被唤醒、超时时间到),线程重新进入可运行状态,等待CPU资源。
- 运行到终止:线程的 run() 方法执行完毕,或者在执行过程中抛出了未捕获的异常,线程进入终止状态。
3 线程的创建
线程创建的方式一般有以下三种:继承Thread、实现runnable接口、实现Callable接口。
3.1 继承Thread类
1、创建自定义的类继承Thread类,并且重写run接口
2、实例化自定义
3、通过实力化对象调用start方法来创建新线,实现Callable接口
public class JavaDemoA {
public static void main(String[] args) {
new MyThread("线程A:").start();
new MyThread("线程B:").start();
new MyThread("线程C:").start();
}
}
class MyThread extends Thread{ //线程的主体类
private String title;
public MyThread(String title){
this.title=title;
}
@Override
public void run() {
for(int i=1;i<=4;i++){
System.out.println(this.title+i);
}
}
}
3.2 实现runnable接口
1、创建自定义类并实现Runable接口,并实现接口中的run方法
2、实例化自定的类
3、将自定义类的实例作为参数Thread类,创建thread实例
4、调用Thread实例的start方法,启动子线程
public class JavaDemoB {
public static void main(String[] args) {
new Thread(new MyThreadB("线程A:")).start();
new Thread(new MyThreadB("线程B:")).start();
new Thread(new MyThreadB("线程C:")).start();
}
}
class MyThreadB implements Runnable{
private String title;
public MyThreadB(String title){
this.title=title;
}
@Override
public void run() {
for(int i=1;i<=5;i++){
System.out.println(title+i);
}
}
}
3.3 实现Callable接口
1、实现Callable接口,并实现call方法
2、创建线程池(Excutors工具类提供的方法创建线程池)
3、创建Callable接口实现类实例
4、将实例对象通过线程池的submit 方法提交给线程池进而创建新的线程
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
public class JavaDemoC {
public static void main(String[] args) throws Exception{
FutureTask<String> task=new FutureTask<>(new MyThreadC());
new Thread(task).start();
System.out.println(task.get());
}
}
class MyThreadC implements Callable<String> {
@Override
public String call() throws Exception {
for(int i=1;i<=5;i++){
System.out.println("线程执行:i="+i);
}
return "Callable实现多线程";
}
}
3.4 三种实现创建线程的优缺点
通过集成Thread类或者实现Runable,Callable接口都可以实现多线程,不过实现Runable接口与实现Callable接口的方式基本相同,只是Callable接口里得到定义的方法有返回值,可以声明抛出异常而已。因此可以将实现Runable接口和实现Callable归为一种方式。这种方式与Thread方式之间的主要差别如下:
1、采用Runable/Callable接口的方式创建多线程的优缺点:
优势:
- 线程类只是实现了Runable接口与Callable接口,换可以继续继承其他类。
- 在这种方式下,多个线程可以共享一个target对象,所以非常合适多个相同线程来处理同一份资源情况,从而可以将cpu。代码和数据分开,形式清晰的模型,较好地体现了面向对象的思想
劣势:
- 编程比较复杂,如果要访问当前线程则必须使用Thread.currentThread() 方法
2、采用Thread类的方法创建多线程的优缺点:
优势:
- 编写简单,如果需要访问当前线程,则无须使用Thread.currentThread()方法,直接使用this即可获得当前线程
劣势:
- 因为线程继承了Thread类,所以不能在继承其他父类。
总结:因此一般采用Runable接口、Callable接口的方式来创建多线程
4. 多线程中的一些常用方法
join():等待此线程死亡后再继续,可使异步线程变为同步线程
interrupt():中断线程,被中断线程会抛InterruptedException
wait():等待获取锁:表示等待获取某个锁执行了该方法的线程释放对象的锁,JVM会把该线程放到对象的等待池中。该线程等待其它线程唤醒
notify():执行该方法的线程唤醒在对象的等待池中等待的一个线程,JVM从对象的等待池中随机选择一个线程,把它转到对象的锁池中。使线程由阻塞队列进入就绪状态
sleep():让当前正在执行的线程休眠,有一个用法可以代替yield函数,就是sleep(0)
yield():暂停当前正在执行的线程对象,并执行其他线程。也就是交出CPU一段时间(其他同样的优先级或者更高优先级的线程可以获取到运行的机会)
4.1 Wait(1000)和Sleep(1000)的区别
1、wait(1000) 方法:wait(1000)表示将锁释放1000毫秒,到时间后如果锁没有被其他线程占用,则再次得到锁,然后wait方法结束,执行后面的代码,如果锁被其他线程占用,则等待其他线程释放锁。注意,设置了超时时间的wait方法一旦过了超时时间,并不需要其他线程执行notify也能自动解除阻塞,但是如果没设置超时时间的wait方法必须等待其他线程执行notify。
-
wait() 方法是Object类的方法,可以在任何对象上调用,包括所有Java对象。
-
当线程调用 wait() 方法时,它会释放它所持有的锁,并进入等待状态,直到被另一个线程调用相同对象上的 notify() 或 notifyAll() 方法唤醒,或者超时时间到达(如果指定了超时时间)。
-
如果没有指定超时时间,线程将一直等待直到被唤醒。
2、sleep(1000) 方法:Thread.Sleep(1000) 意思是在未来的1000毫秒内本线程不参与CPU竞争,1000毫秒过去之后,这时候也许另外一个线程正在使用CPU,那么这时候操作系统是不会重新分配CPU的,直到那个线程挂起或结束,即使这个时候恰巧轮到操作系统进行CPU 分配,那么当前线程也不一定就是总优先级最高的那个,CPU还是可能被其他线程抢占去。另外值得一提的是Thread.Sleep(0)的作用,就是触发操作系统立刻重新进行一次CPU竞争,竞争的结果也许是当前线程仍然获得CPU控制权,也许会换成别的线程获得CPU控制权。
-
sleep() 是Thread类的静态方法,用于让当前线程暂停执行指定的毫秒数。
-
调用 sleep() 方法不会释放任何锁资源,线程在睡眠期间仍然保持对锁的持有状态。
-
一般用于线程需要暂停执行一段时间而不需要释放锁的情况。
4.2 sleep和yield区别
- sleep()方法会给其他线程运行的机会,而不考虑其他线程的优先级,因此会给较低线程一个运行的机会;yield()方法只会给相同优先级或者更高优先级的线程一个运行的机会。
- 当线程执行了sleep(long millis)方法后,将转到阻塞状态,参数millis指定睡眠时间;当线程执行了yield()方法后,将转到就绪状态。
- sleep()方法声明抛出InterruptedException异常,而yield()方法没有声明抛出任何异常
- sleep()方法比yield()方法具有更好的移植性
4.3 join()的使用
在多线程并发执行中每个线程对象都会交替执行,如果某个线程对象需要优先完成执行,可以使用 join ()方法强制执行,待其执行完毕后其他线程才会继续执行
例如:需要T1、T2、T3顺序执行,在T2线程中调用T1线程的 join() 方法,在T3线程中调用T2线程的 join() 方法,这样当前线程会等待被调用线程执行完毕后再继续执行,就可以保证T1、T2、T3顺序执行。
4.4 Start、Run方法区别
- Start 方法:start() 方法是 Thread 类的一个方法,用于启动一个新的线程并让其执行线程体(即 run() 方法)。当调用 start() 方法时,系统会安排线程在合适的时机执行,并且可以并行执行多个线程。
- Run 方法:run() 方法是实际定义线程执行体的地方。当线程被启动并进入可运行状态时,会调用 run() 方法来执行具体的任务逻辑。run() 方法定义在 Runnable 接口中,线程类通过实现 Runnable 接口或继承 Thread 类并重写 run() 方法来定义自己的线程逻辑。
4.5 Wait 和 Notify 的使用
- wait() 方法是 Object 类的方法,用于将当前线程置入等待状态(阻塞),直到其他线程调用相同对象的 notify() 或 notifyAll() 方法来唤醒该线程。wait() 方法只能在同步方法或同步块中调用,否则会抛出 IllegalMonitorStateException 异常。
- notify() 方法是 Object 类的方法,用于唤醒在此对象监视器上等待的单个线程。如果有多个线程在等待,则只会唤醒其中一个线程。notifyAll() 方法则会唤醒所有在此对象监视器上等待的线程。
总结:wait() 和 notify() 方法必须在同步方法或同步块中调用,这是因为这些方法需要获取对象的监视器(即对象的锁)。如果不在同步块中调用这些方法,就无法确保线程在调用 wait() 方法时能够释放对象的锁,从而导致其他线程无法调用该对象上的 notify() 或 notifyAll() 方法来唤醒等待的线程
4.6 volatile关键字运用
1、可见性
用来修饰被不同线程访问和修改的变量:某线程对 volatile 变量的修改,对其他线程都是可见的。即获取 volatile 变量的值都是最新的。
所以要使用 volatile 的内存可见性特性的话得满足两个条件:
- 能确保只有单一的线程对共享变量的只进行修改。
- 变量不需要和其他状态变量共同参与不变的约束条件
2、禁止指令重排序:
volatile 关键字修饰的变量,对编译器和处理器的优化是有限制的。这样可以防止指令重排序,确保代码的执行顺序与预期一致
5 线程池的创建方式和工作原理
5.1 线程池的创建方式
线程池的创建方式多种多样,但总体来说可以分为两大类:通过ThreadPoolExecutor类直接创建,以及通过Executors工厂类间接创建。具体方式包括:
1、通过ThreadPoolExecutor创建:
这是最原始的创建线程池的方式,它提供了丰富的配置选项来控制线程池的行为。通过构造函数ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)可以设置核心线程数、最大线程数、线程存活时间、时间单位、任务队列、线程工厂以及拒绝策略等参数。
2、通过Executors工厂类创建:
Executors类提供了多种便捷的方法来创建线程池,这些方法内部封装了ThreadPoolExecutor的实例化过程,使得创建线程池变得更加简单。具体方法包括:
- Executors.newFixedThreadPool(int nThreads):创建一个固定大小的线程池。
- Executors.newCachedThreadPool():创建一个可缓存的线程池,线程数根据任务动态调整。
- Executors.newSingleThreadExecutor():创建单个线程的线程池,保证任务按顺序执行。
- Executors.newScheduledThreadPool(int corePoolSize):创建一个可以执行延迟任务的线程池。
- Executors.newSingleThreadScheduledExecutor():创建单个线程的可以执行延迟任务的线程池。
- Executors.newWorkStealingPool(int parallelism):JDK 1.8+中新增,创建一个抢占式执行的线程池(任务执行顺序不确定)。
5.2 线程池的工作原理
线程池的工作原理可以概括为以下几个步骤:
- 任务提交:当有任务提交给线程池时,线程池首先会判断核心线程池中的线程是否都在执行任务。
- 核心线程池处理:如果核心线程池中的线程数量未达到核心线程数,则创建一个新的线程来执行任务。如果核心线程池中的线程都在执行任务,则进入下一步。
- 任务队列处理:将任务添加到阻塞队列中等待执行。如果阻塞队列已满,则进入下一步。
- 非核心线程池处理:如果当前线程数小于最大线程数,则创建新的线程(非核心线程)执行任务。如果当前线程数已经达到最大线程数,则执行饱和策略。
- 饱和策略处理:当线程池和阻塞队列都满了时,需要执行饱和策略来处理新提交的任务。Java提供了几种内置的饱和策略,如直接拒绝任务并抛出异常(AbortPolicy)、由提交任务的线程执行该任务(CallerRunsPolicy)、丢弃任务(DiscardPolicy)以及丢弃队列中最老的任务(DiscardOldestPolicy)。
- 线程回收:当线程池中的线程空闲时间超过指定的存活时间时,且当前线程数大于核心线程数,则多余的空闲线程会被回收。
- 任务执行:线程池中的线程会不断从阻塞队列中取出任务来执行,直到队列为空。
6 线程安全问题
6.1 什么是线程安全
线程安全是指在多线程环境中,当多个线程同时访问共享资源时,保证程序在并发情况下仍能正确运行的状态
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。
6.2 线程安全问题的原因
1、【根本原因】多个线程之间的调度顺序是 “随机” 的,操作系统使用 “抢占式” 执行的策略来调度线程
和单线程不同的是,多线程下,代码的执行顺序产生了更多的变化,以往只需要考虑代码在一个固定的顺序下执行,执行正确即可。现在则要考虑多线程下,N种执行 顺序下,代码执行的结果都得正确
当前主流的操作系统,都是这样的抢占式执行
2、多个线程同时修改同一个变量,容易产生线程安全问题
一个线程修改一个变量,多个线程读取同一个变量,多个线程修改多个变量,这三种情况都没事
这个条件其实说的是 代码结构 的问题,我们可以通过调整代码结构来规避这种情况的发生,这也是解决线程安全问题,最主要的切入手段
3、进程的修改,不是 “原子的”
如果修改操作,能够按照原子的方式来完成,此时也不会有线程安全问题
4、内存可见性,引起的线程安全问题
5、指令重排序,引起的线程安全问题
6.3 解决线程安全问题的几种方试
1、单服务器下使用synchronized和Lock都可以实现代码的同步。简单点说,就是在修改或访问可变状态时加锁,独占对象,让其他线程进不来,以达到线程安全的效果
2、使用数据库的乐观锁机制.即就是在对共享数据进行操作是先查出数据的版本号再去做更新或者插入的操作是带上查出来的版本号去操作原理(分布式环境可用)。
3、使用数据库的悲观锁机制。即利用数据库的行锁或者是表锁来解决线程安全问题(for update关键字)(分布式环境可用)
7 多线程中常见的锁
7.1. 乐观锁(Optimistic Locking)
- 定义:乐观锁假设数据不会发生冲突,因此在读取数据时不加锁。在更新数据时才检查数据是否有冲突。
- 实现:通常使用版本号或时间戳来检查数据是否被其他线程修改过。如果数据版本号在更新时与原始版本号相同,则更新成功;否则,更新失败。
- 优点:减少了锁的开销,提高了并发性能。适用于读多写少的场景。
- 缺点:如果冲突频繁发生,可能会导致更新失败,需要重试。
7.2 悲观锁(Pessimistic Locking)
- 定义:悲观锁假设数据会发生冲突,因此在读取数据时就会加锁。通常在更新数据前获取锁,确保数据在更新期间不会被其他线程修改。
- 实现:使用互斥锁(如synchronized、ReentrantLock等)实现。加锁后,其他线程必须等待锁释放才能访问数据。
- 优点:确保数据的一致性。适用于写多的场景。
- 缺点:加锁和解锁增加了额外的开销。•可能导致线程阻塞,影响并发性能。
7.3 读写锁(ReadWriteLock)
- 定义:读写锁允许多个线程同时读取共享资源,但在写操作时是独占的。适用于读多写少的场景。
- 实现:使用ReadWriteLock接口和ReentrantReadWriteLock实现。读操作可以并发进行,写操作独占资源。
- 优点:提高了并发性能,尤其是在读操作远多于写操作的情况下。适用于读多写少的场景。
- 缺点:实现相对复杂。写操作可能会阻塞所有读操作。
7.4 同步共享锁(Synchronized Lock)
- 定义:同步共享是指多个线程共享同一份数据,并通过同步机制确保数据的一致性。通常使用互斥锁来实现。
- 实现:使用synchronized关键字或ReentrantLock等锁机制。保证在任何时候只有一个线程可以访问共享资源。
- 优点:简单易用,确保数据的一致性。
- 缺点:可能导致线程阻塞,影响并发性能。
7.5 分段锁 (Lock Striping)
- 定义:分段锁将数据分割成多个部分,每个部分有自己的锁。适用于大型数据结构,如ConcurrentHashMap。
- 实现:在Java 8之前的ConcurrentHashMap中使用Segment实现。每个Segment相当于一个小的HashMap,有自己的锁。
- 优点:减少了锁的竞争,提高了并发性能。•适用于大型数据结构。
- 缺点:实现复杂,管理多个锁增加了一定的开销。
7.6 死锁 (Deadlock)
- 定义:死锁发生在多个线程互相等待对方持有的锁时,导致所有线程都无法继续执行的状态。通常需要外部干预来解决。
- 死锁形成的四大条件:(1)互斥条件:至少有一个资源必须保持在一段时间内由一个进程排他控制。(2)请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。(3)不剥夺条件:进程已获得的资源,在末使用完之前不能强行剥夺。(4)循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
- 预防:(1)避免嵌套锁。(2)按照固定的顺序获取锁。(3)使用超时机制尝试获取锁。