主要总结了Java在多线程开发时遇到的一些知识点,疑惑和总结,欢迎大佬们指点交流
1.何为进程、线程
1.1进程:
简单的说,一个进程就是一个程序执行的全部过程,是系统运行程序的基本单位。系统运行一个程序的过程既是一个进行从创建、运行到最后消亡的过程,而一个进程中可能会包含多个线程。
举个例子,像我们电脑中运行的一个个.exe程序就是windos系统下的一个个进程,如下图所示。
具体到我们的Java程序中,main()方法的开始执行既是一个JVM(Java虚拟机)进程的开始,而main()方法所在的线程也就是该进程下的一个线程,称为主线程。
1.2线程
想必您已经发现,以上对进程的介绍中已多次出现线程的概念了,但是具体什么是线程呢?其实,线程和进程是很相似的概念,理解时也常常放在一起进行比较,刚才说到,进程是一个程序执行的基本单位,而线程是比进程更小的程序执行的单位。一个进程下可以存在一个或多个线程,但是多个线程在共享一个类的进程的堆和方法区资源的同时,每个线程有属于自己的程序计数器、虚拟机栈和本地方法栈,所以系统运行程序时,线程工作或是多个线程间切换工作时的代价要远小于进程,效率更高,从这个角度说,所以技术人员也常把线程看作是轻量级的进程来理解。
1.3进程和线程的关系
简单来说,一个进程里可以有多个线程,线程是进程下更小的程序运行单位,多个线程共享一个进程下的堆和方法区,而每个线程有具有自己的程序计数器、虚拟机栈和本地方法栈。
进程和线程的最大不同为:各进程间一般是互相独立的,而线程间则很可能会互相影响,进行的创建及转换代价大,但是利于资源的管理和维护,线性的代价小,但不利于资源的统一管理和维护。
2.为什么要使用多线程
2.1并行和并发
并行:对同一个时间段,多个任务都有执行,但具体到单位时间内并不一定同时进行
并发:单位时间内,多个任务同时进行
2.2使用多线程的原因
从线程与进程性能上的区别来分析,线程作为轻量级的进程,程序执行的最小单位,在进行线程简单切换上代价远小于进程,而现在又是多核CPU的发展时代,满足了多个线程可以同时运行的硬件要求,减少了线程上下文切换的代价;从时代的发展趋势来看,现在对系统运行的并发量要求越来越高,动则成百上千万的并发量也只有多线程能实现,因为多线程编程是高并发系统开发的基础。
2.3多线程可能带来的问题
内存泄漏、上下文切换、死锁。。。
3.线程的生命周期
在Java中一个线程只能处于以下6中生命状态中(图片引自《Java并发编程艺术》)
一个线程的生命状态随着程序的运行而动态变化,Java线程的生命状态变化如下图所示(图片引自《Java并发编程艺术》)
简单来讲,一个Java线程,创建后,即处于NEW状态,调用start()方法后,线程开始准备运行,此时处于READY状态,当处于READY状态的线程获得了CPU时间片后,真正达到RUNNING的运行状态。当线程执行wait()方法后,线程进入WAITING状态,此时的线程需要其他线程的通知才能返回刚才的运行状态,而TIME_WAITING状态是指等待超时的状态,当设置的等待时间到后,原等待线程返回RUNNING状态。当线程调用同步方法时,如果没有获得锁,则进入BLOCKED状态。最后,当线程执行完Runnable的run()方法后将会进入到最终消亡TERMINATED状态,此时,一个线程的生命周期结束。
4.上下文切换
我们知道,对于一个CPU核来说,在同一时刻只能运行一个线程,而运行程序时的线程个数往往要大于CPU核的个数,这就涉及到CPU核在多线程间的分配问题,而CPU是怎么解决的呢?为了让多个线程都能得到有效的运行,CPU通过为每个线程分配时间片的形式,当一个线程的时间片结束后,CPU会重新处于就绪状态而给其他线程运行,而当前线程会保存自己的状态以便下次再轮到该线程时能继续运行,这样的通过时间片的形式实现多线程切换运行的方式即为上下文切换。
5.多线程下可能遇到的线程死锁是什么?如何避免?
5.1线程死锁
线程死锁是多线程在工作时遇到的阻塞状态,因为多线程中的一个或多个线程都在等待其他线程访问的资源被释放,而陷入的无限期的阻塞状态,导致被阻塞的程序永远不会结束运行。
如下图所示,线程1持有资源1,要访问资源2,同时线程2持有资源2,要访问资源1,两个线程都在等对方释放资源,从而进入一直阻塞的死锁状态。
5.2产生死锁的条件
(1)互斥条件:某一资源在一个时刻只由一个线程所占用;
(2)请求与保持:一个线程因请求其他资源而阻塞时,以获得的资源保持不释放;
(3)不剥夺条件:线程已经获得的资源在未使用结束前不能被其他线程强行使用,只有该线程使用结束后才能释放该资源;
(4)循环等待条件:若干线程之间形成首尾相连的循环等待资源关系。
5.3如何避免死锁
针对产生死锁的4个条件可以联系到,打破其中的任意一个条件即可避免线程死锁。
(1)打破互斥条件:线程间的互斥不能被打破,因为我们就是要用锁保证他们之间的互斥性;
(2)打破请求与保持条件:可以改为线程一次性申请所有资源;
(3)打破不剥夺条件:若某线程在占用某资源的情况下访问其他资源而访问失败时,要主动释放已经占有的资源;
(4)打破循环等待条件:线程间按照特定顺序申请资源,按相反顺序释放资源。
5.4线程阻塞之sleep()和wait()方法比较
区别:
sleep()方法没有释放锁,wait()方法释放了锁;
wait()方法被调用后,线程不会自动苏醒,需要其他线程调用同一对象的notify()方法或notifyAll()方法去唤醒。而sleep()方法执行完后,线程会自动苏醒;
wait()方法常用于多线程间的通信和交互,而sleep()常用于线程暂停执行。
相同点:
wait()和sleep()方法都可以实现线程的暂停功能。
6.常见疑惑之start()方法和run()方法
问:我们知道线程执行start()方法时会自动调用run()方法,那么为什么不可以直接调用run()方法呢?
解释:先介绍一下一个线程从创建到执行的过程。首先,通过new一个Thread,我们新建了一个线程,或者说该线程处于创建状态;然后,调用start()方法,该线性会启动并进入准备就绪状态,当该线程分配到CPU时间片时就可以运行了,在这里面会调用run()方法。
而start()方法并不是只实现了调用run()方法这一个操作,在期间start()会执行线程的相应的准备工作,然后自动执行run()方法里的内容,这才是真正的多线程创建和执行操作。如果直接执行run()方法的话,系统会把run()方法视为main主线程下的一个普通方法,而不会在某个线程中执行,并不是多线程工作。
7.并发编程
特点或要求:
(1)原子性:所有操作都得到执行不会受到任何因素干扰而中断,要么都执行,要么都不执行。
(2)可见性:一个线程对共享数据进行了修改,其他线程可以看到这种修改,进而使用修改后的数据。
(3)有序性:Java编译器会对代码进行优化,代码的编写顺序不一定是最终的执行顺序,JVM的指令重排。
8.多线程之重要的关键字
8.1synchronized关键字
8.1.1功能
synchronized的意思为已同步化的,从单词意思中就可以大体知道该关键字的作用啦。synchronized关键字解决多线程间访问资源时的同步性问题,被synchronized关键字修饰后的方法或代码块能够保证在任意时刻只有一个线程在执行。
8.1.2修饰的对象
(1)实例方法:给当前实例对象加了一把锁,进入同步代码前要先获得当前实例的锁;
(2)静态方法:给当前类加了一把锁,作用于该类的所有对象实例;
(3)代码块:给指定对象加了一把锁,进入该代码块前要先获得锁。
8.1.3常见使用场景之单例模式
下例为双重校验锁实现对象单例(线程安全)
注意:
volatile关键字的修饰同样很重要,可以避免指令重排,实现多线程下的安全正常使用,具体原因为:对代码people = new People();具体执行分为以下三部分:
(1)为people分配内存空间;
(2)初始化people;
(3)将people指向分配的内存地址。
Java虚拟机(JVM)具有指令重排的特性,也就是说执行顺序可能变为1->3->2,这种情况在单线程下没有问题,但是在多线程下会发生一个线程获得了一个没有实例化的实例。比如说,当线程1执行了1和3,在此时,线程2调用了getPeopleInstance()后发现people不为空,因此返回了people,但是该people还未进行初始化。而volatile关键字可以禁止JVM的指令重排。
8.1.4比较synchronized和ReentrantLock的异同
(1)都是可重入锁。所谓可重入锁就是说自己本身可以再获取自己的内部锁。例如,一个线程获取了某个对象的锁,但这个锁还没有释放,当其再次想要获得这个对象的锁的时候还是可以的,如果不能重入锁的话就会造成死锁。一个线程每次获取的锁,锁的计数器都会加1,只有锁的计数器为0时才能释放锁。
(2)synchronized是基于JVM的,而ReentrantLock是基于API的。
(3)ReentrantLock比synchronized增加了一些高级功能。主要为:
等待可中断:lock.lockInterruptibly()可以实现中断等待。
可实现公平锁:ReentrantLock可以指定是公平锁还是非公平锁,而synchronized只能实现非公平锁,公平锁就是先等待的线程也最先获得锁。
可实现选择性通知:线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。
8.2volatile关键字
目前的Java内存模型中,一个线程可以把变量保存在本地内存中,而不是直接在主存中进行读写操作,这样就可能出现一个线程在主存中修改了变量值,而另一个线程还是用的它本地内存中的拷贝值,造成数据不一致,线程不安全。而volatile的作用就是告诉JVM,我修饰的这个变量是易变的,所以多线程每次使用都得到主存中进行读写操作。简单来说,volatile关键字的作用为保证变量的可见性和禁止JVM的指令重排。
8.3synchronized和volatile关键字的异同
(1)性能:volatile是线程同步的轻量级实现,执行性能要优于synchronized
(2)修饰对象:volatile只能修饰变量,而synchronized可以修饰方法和代码块
(3)是否会阻塞:多线程访问下,volatile不会阻塞,而synchronized可能会发生阻塞
(4)用途:volatile主要解决变量在多线程间的可见性问题,而synchronized主要解决多线程访问时资源的同步性问题。
9.线程池
9.1什么是线程池,为什么要使用线程池
在一个应用程序中,通常需要多次使用线程,也就是说,完成一个程序执行需要多次线程的创建和销毁过程,而创建并销毁线程的过程会消耗宝贵的内存。为了解决重复创建和销毁线程带来的资源消耗问题,提出了线程池的概念,所谓线程池,通俗理解就是一个池子,里面提前准备了一些线程,具体为:提前创建一些线程,它们的集合称为线程池,线程池在系统启动时即创建大量空闲的线程,程序将一个任务传给线程池,线程池就会启动一条线程来执行这个任务,执行结束以后,该线程并不会销毁,而是再次返回线程池中成为空闲状态,等待执行下一个任务。通过线程池的方式可以方便的管理线程,也可以减少内存的消耗。
9.2如何创建线程池
(1)通过构造方法实现:ThreadPoolExecutor()
(2)通过Executor框架的工具类Executors来实现。
我们可以创建三种类型的ThreadPoolExecutor:
SingleThreadExecutor:该方法返回一个里面只有一个线程的线程池。只能有一个任务同时执行,若此时有多于一个任务,则多出的任务暂存在任务队列中,等待线程空闲时一次执行任务队列中的任务。
FixedThreadPool:该方法返回一个固定线程数量的线程池。无论有无任务执行,有多少任务执行,线程池中的线程数量都不变。当有一个新的任务要提交时,此时如果线程池中又空闲的线程,那么执行该任务;否则,择该任务会被存放在一个任务队列中,等待有空闲线程时再处理任务队列中的任务。
CachedThreadPool:该方法返回一个线程数量可变的线程池。有任务提交时,若此时有空闲线程,则优先使用可复用的空闲线程。若线程池中的所有线程均已在工作,那么会创建新的线程来处理该任务。任务执行完毕后,将返回线程池进行复用。
9.3ThreadPoolExecutor类分析
ThreadPoolExecutor类提供了4个构造线程池的方法。分别是:
ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit,BlockingQueue<Runnable>workQueue)
ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit,BlockingQueue<Runnable>workQueue,ThreadFactory)
ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit,BlockingQueue<Runnable>workQueue,
RejectedExecutionHandler)
ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit,BlockingQueue<Runnable>workQueue,ThreadFactory,
RejectedExecutionHandler)
核心参数:
corePoolSize:核心线程数定义了最小可以同时进行的线程数量;
maximumPoolSize:当队列中存放的任务数量达到队列容量时,当前可以同时运行的线程数量变为最大线程数;
workQueue:当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到,新任务就被存放在任务队列中。
10.多线程的实现
10.1实现Runnable接口和Callable接口比较
Runnable接口和Callable接口,都是可以编写多线程程序的接口,都采用Thread.start()启动线程。
从Java1.0就有Runnable接口,Java1.5时引入了Callable,目的是处理Runnable不支持的用例。Runnable接口并不会返回结果或抛出异常,但是Callable接口可以实现。
10.2execute()方法和submit()方法比较
(1)execute()方法用于不需要返回值的任务中,调用该方法无法判断任务是否被线程池执行成功与否;
(2)submit()方法用于提交需要返回值的任务中,调用该方法后,线程池返回一个Future类型的对象,通过该对象判断任务是否执行成功。