进程与线程
- 线程是一个程序内部的顺序控制流,而一个程序是一个进程,进程之间是相互独立存在的。
- 进程是一个静态的概念,本身是不会动的,进程想要执行任务就需要依赖线程。换句话说,就是进程中的最小执行单位就是线程,并且一个进程中至少有一个线程。比如Java程序中的main()方法就是一个主线程。
- 一个进程可以拥有多个线程,每个线程使用其所属进程的栈空间。
线程与进程的一个主要区别是,同一进程内的多个线程会共享部分状态,多个线程可以读写同一块内存 (一个进程无法直接访问另一进程的内存) 。同时,每个线程还拥有自己的寄存器和栈,其他线程可以读写这些栈内存。
Java中的线程封装
Java 作为与平台无关的编程语言,必然会对底层(操作系统)提供的功能进行进一步的封装,以平台无关的编程接口供程序员使用,进程与线程作为操作系统核心概念的一部分无疑亦是如此。
在 Java 语言中,对进程和线程的封装,分别提供了 Process 和 Thread 相关的一些类,Process是进程相关的封装,在这里不详细说明。
Java中,线程通过java.lang.Thread类来实现的,每一个Thread对象代表一个新的线程。
常用的实现多线程的2种方式:
- 实现runnable接口
- 从Thread类继承
可以通过创建Thread的实例来创建新的线程。只要new一个Thread对象,一个新的线程也就出现了。每个线程都是通过某个特定的Thread对象所对应的方法 run() 来完成其操作的,方法run()称为线程体。
实现runnable接口创建线程
在Runable接口中只有一个run()方法,只要:
- 自定义类或内部类中实现接口
public class MyRunnable implements Runnable{
……
}
- 重写run()方法
public void run(){
for(int i=0;i<10;i++){
System.out.println("Runner1:"+i);
}
}
- 创建该类实例对象新建线程,调用start()方法启动
public static void main(String[] args) {
//创建线程执行目标类对象
Runnable runn = new MyRunnable();
//将Runnable接口的子类对象作为参数传递给Thread类的构造函数
Thread thread = new Thread(runn);
Thread thread2 = new Thread(runn);
//开启线程
thread.start();
thread2.start();
for (int i = 0; i < 10; i++) {
System.out.println("main线程:正在执行!"+i);
}
}
程序执行到start()方法后会开辟一条新的线程,主线程不受影响继续向下执行。
如果不是start()方法而是执行run()方法,那么就不算开辟新线程,只是在主线程中执行run()方法,执行完才能继续执行main()。
通常有一个简便的内部类写法:
public static void main(String[] args) {
Runnable r = new Runnable() {
public void run() {
for (int x = 0; x < 40; x++) {
System.out.println(Thread.currentThread().getName()+"="+x);
}
}
};
new Thread(r).start();
}
从Thread类继承
该类的原理是它本身就是实现的Runable接口
使用步骤:
- 自定义类继承Thread类
public class MyThread extends Thread {
……
}
- 重写run()方法
public void run(){
for(int i=0;i<10;i++){
System.out.println("Runner1:"+i);
}
}
- 创建类实例对象,新建线程运行
public static void main(String[] args) {
//创建两个线程任务
MyThread d = new MyThread();
MyThread d2 = new MyThread();
d.run();//没有开启新线程, 在主线程调用run方法
d2.start();//开启一个新线程,新线程调用run方法
}
同样也有内部类形式
public static void main(String[] args) {
Runnable r = new Thread() {
public void run() {
for (int x = 0; x < 40; x++) {
System.out.println(Thread.currentThread().getName()+"="+x);
}
}
};
r.start()
}
选择
一般建议通过 “Runnable” 开启多线程,好处:
- Runnable是接口,避免了单继承局限性,有更好的扩展性
- 该类一旦创建Thread的子类对象,即是线程对象,又有线程
- 多个线程如果都是基于某一个Runnable对象建立的,它们会共享对象上的资源
线程的多种状态
线程基本分为五种状态,分别是:
- New:创建状态,线程被创建,没有调用start()
- Runnable:就绪状态,当调用了线程对象的start()之后,该线程就进入了就绪状态,但是此时线程调度程序还没有把该线程设置为当前线程,此时处于就绪状态,等待资源分配。
- Running:运行状态,线程调度程序将处于就绪状态的线程设置为当前线程,此时线程就进入了运行状态,开始运行run函数当中的代码
- Blocked:阻塞状态,线程进入等待状态,线程因为某种原因,放弃了CPU的使用权。阻塞的几种情况:
- A. 等待阻塞:运行的线程执行了wait(),JVM会把当前线程放入等待队列。
- B. 同步阻塞:运行的线程在获取对象的同步锁时,如果该同步锁被其他线程占用了,JVM会把当前线程放入锁池中。
- C. 其他阻塞:运行的线程执行sleep(),join()或者发出IO请求时,JVM会把当前线程设置为阻塞状态,当sleep()执行完,join()线程终止,IO处理完毕线程再次恢复
- Dead:死亡状态,一个线程的run方法执行结束或者调用stop方法后,该线程就会死亡。对于已经死亡的线程,无法再使用start方法令其进入就绪。
注:在线程运行之后、从等待或者睡眠中回来之后,会先处于就绪状态,再获取cpu资源进入运行状态。
线程控制的基本方法
方法 | 功能 |
---|---|
isAlive() | 判断线程是否还存活(有无终止) |
getPriority() | 获得线程优先级数值 |
Thread.sleep() | 将当前线程睡眠指定秒数,让出cpu资源,但是它的监控状态依旧保持,也不会释放锁对象,唤醒只能等指定时间过去 |
join() | 调用某线程的该方法,将指定线程与该线程“合并”,即等待该线程结束,再恢复当前线程的运行 |
yield() | 让出cpu资源,当前线程进入就绪队列等待调度 |
wait() | 当前线程进入对象的wait pool,此时锁对象也会释放 |
notify() | 唤醒对象的wait pool中的一个等待线程 |
线程的优先级别
Java提供一个线程调度器来监控程序中启动后进入就绪状态的所有线程。线程调度器按照线程的优先级来决定应调度哪个线程来执行。
线程的优先级用数字表示,范围从1到10,一个线程的缺省优先级是5
注意:
有时间片轮循机制时。“高优先级线程”被分配CPU的概率高于“低优先级线程”。根据时间片轮循调度,所以能够并发执行。无论是是级别相同还是不同,线程调用都不会绝对按照优先级执行,每次执行结果都不一样,调度算法无规律可循,所以线程之间不能有先后依赖关系。
线程安全
多个线程同时运行,可能会出现变量与预想的不一致的情况,即线程不安全
多个线程对一个公共数据修改,一般都要考虑线程同步
解决办法:
- 同步代码块(细粒度锁)
在需要加锁的代码块上添加synchronized
synchronized (锁对象) {
//可能会产生线程安全问题的代码
}
- 锁对象可以是任意对象(包括this)
- 但要使用同一个锁对象才能保证线程安全
-
同步方法(粗粒度锁)
- 在方法声明上加上synchronized,此时,同步方法中的锁对象就是this
public synchronized void method(){ //可能会产生线程安全问题的代码 }
- 在方法声明上加上static synchronized (静态同步方法),此时,静态同步方法中的锁对象是类名.class
public static synchronized void method(){ //可能会产生线程安全问题的代码 }
线程死锁问题
所谓死锁是指多个线程因竞争资源而造成的一种僵局(互相等待),若无外力作用,这些进程都将无法向前推进。
在加锁操作的时候都需要注意可能产生的死锁问题
/*这个小程序模拟的是线程死锁的问题*/
public class TestDeadLock implements Runnable {
public int flag = 1;
static Object o1 = new Object(), o2 = new Object();
public void run() {
System.out.println(Thread.currentThread().getName() + "的flag=" + flag);
/*
* 运行程序后发现程序执行到这里打印出flag以后就再也不往下执行后面的if语句了
* 程序也就死在了这里,既不往下执行也不退出
*/
// 这是flag=1这个线程
if (flag == 1) {
synchronized (o1) {
// 使用synchronized关键字把对象01锁定了
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o2) {
/*
* 前面已经锁住了对象o1,只要再能锁住o2,那么就能执行打印出1的操作了
* 可是这里无法锁定对象o2,因为在另外一个flag=0这个线程里面已经把对象o1给锁住了
* 尽管锁住o2这个对象的线程会每隔500毫秒睡眠一次,可是在睡眠的时候仍然是锁住o2不放的
*/
System.out.println("1");
}
}
}
/*
* 这里的两个if语句都将无法执行,因为已经造成了线程死锁的问题
* flag=1这个线程在等待flag=0这个线程把对象o2的锁解开,
* 而flag=0这个线程也在等待flag=1这个线程把对象o1的锁解开
* 然而这两个线程都不愿意解开锁住的对象,所以就造成了线程死锁的问题
*/
// 这是flag=0这个线程
if (flag == 0) {
synchronized (o2) {
// 这里先使用synchronized锁住对象o2
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o1) {
/*
* 前面已经锁住了对象o2,只要再能锁住o1,那么就能执行打印出0的操作了 可是这里无法锁定对象o1,因为在另外一个flag=1这个线程里面已经把对象o1给锁住了 尽管锁住o1这个对象的线程会每隔500毫秒睡眠一次,可是在睡眠的时候仍然是锁住o1不放的
*/
System.out.println("0");
}
}
}
}
public static void main(String args[]) {
TestDeadLock td1 = new TestDeadLock();
TestDeadLock td2 = new TestDeadLock();
td1.flag = 1;
td2.flag = 0;
Thread t1 = new Thread(td1);
Thread t2 = new Thread(td2);
t1.setName("线程td1");
t2.setName("线程td2");
t1.start();
t2.start();
}
}
如何避免死锁
在有些情况下死锁是可以避免的。三种用于避免死锁的技术:
- 加锁顺序(线程按照一定的顺序加锁)
- 加锁时限(线程尝试获取锁的时候加上一定的时限,超过时限则放弃对该锁的请求,并释放自己占有的锁)
- 死锁检测(可以依靠map、graph等数据结构实现)
线程池
多线程的异步执行方式,虽然能够最大限度发挥多核计算机的计算能力,但是如果不加控制,反而会对系统造成负担。线程本身也要占用内存空间,大量的线程会占用内存资源并且可能会导致Out of Memory。即便没有这样的情况,大量的线程回收也会给GC带来很大的压力。
为了避免重复的创建线程,线程池的出现可以让线程进行复用。通俗点讲,当有工作来,就会向线程池拿一个线程,当工作完成后,并不是直接关闭线程,而是将这个线程归还给线程池供其他任务使用。
使用线程池的优势
- 降低系统资源消耗,通过重用已存在的线程,降低线程创建和销毁造成的消耗;
- 提高系统响应速度,当有任务到达时,通过复用已存在的线程,无需等待新线程的创建便能立即执行;
- 方便线程并发数的管控。因为线程若是无限制的创建,可能会导致内存占用过多而产生OOM,并且会造成cpu过度切换(cpu切换线程是有时间成本的(需要保持当前执行线程的现场,并恢复要执行线程的现场))。
- 提供更强大的功能,延时定时线程池。
线程池流程
- 判断核心线程池是否已满,没满则创建一个新的工作线程来执行任务。已满则。
- 判断任务队列是否已满,没满则将新提交的任务添加在工作队列,已满则。
- 判断整个线程池是否已满,没满则创建一个新的工作线程来执行任务,已满则执行饱和策略。
线程池使用
ThreadPoolExecutor类是java线程池中的核心类,该类通常用的构造器有:
public ThreadPoolExecutor( int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit paramTimeUnit,
BlockingQueue<Runnable> paramBlockingQueue,
ThreadFactory paramThreadFactory,
RejectedExecutionHandler paramRejectedExecutionHandler) {
……
}
参数解释:
-
corePoolSize:线程池的核心线程数,核心线程不会被回收,即使没有任务执行,也会保持空闲状态。
线程池默认没有任何线程,当有任务过来的时候才会去创建创建线程执行任务。换个说法,线程池创建之后,线程池中的线程数为0,当任务过来就会创建一个线程去执行,直到线程数达到corePoolSize 之后,就会被到达的任务放在队列中。(注意是到达的任务)。
换句更精炼的话:corePoolSize 表示允许线程池中允许同时运行的最大线程数。
如果执行了线程池的prestartAllCoreThreads()方法,线程池会提前创建并启动所有核心线程。 -
maximumPoolSize:线程池允许的最大线程数,他表示最大能创建多少个线程。maximumPoolSize肯定是大于等于corePoolSize。
-
keepAliveTime:表示线程没有任务时最多保持多久然后销毁。
默认情况下,只有线程池中线程数大于corePoolSize 时,keepAliveTime 才会起作用。换句话说,当线程池中的线程数大于corePoolSize,并且一个线程空闲时间达到了keepAliveTime,那么就是shutdown。 -
Unit:keepAliveTime 的单位。
-
workQueue:一个阻塞队列,用来存储等待执行的任务,当线程池中的线程数超过它的corePoolSize的时候,线程会进入阻塞队列进行阻塞等待。通过workQueue,线程池实现了阻塞功能。
-
threadFactory:线程工厂,用来创建线程。通常我们会自顶一个threadFactory设置线程的名称,这样我们就可以知道线程是由哪个工厂类创建的,可以快速定位。
-
handler:表示当拒绝处理任务时的策略。当线数量达到maximumPoolSize大小,并且workQueue也已经塞满了任务的情况下,线程池会调用handler拒绝策略来处理请求。
系统默认的拒绝策略有以下几种:
- AbortPolicy:为线程池默认的拒绝策略,该策略直接抛异常处理。
- DiscardPolicy:直接抛弃不处理。
- DiscardOldestPolicy:丢弃队列中最老的任务。
- CallerRunsPolicy:将任务分配给当前执行execute方法线程来处理。
我们还可以自定义拒绝策略,只需要实现RejectedExecutionHandler接口即可,友好的拒绝策略实现有如下:
- 将数据保存到数据,待系统空闲时再进行处理
- 将数据用日志进行记录,后由人工处理
java线程池的对比
Executors类提供了4种不同的线程池:
- newCachedThreadPool(创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。可以无限扩大,适用于负载较轻的场景,执行短期异步任务。)
- newFixedThreadPool(创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。适用于负载较重的场景,对当前线程数量进行限制)
- newScheduledThreadPool(创建一个定长线程池,支持定时及周期性任务执行。)
- newSingleThreadExecutor(创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。)
execute()和submit()区别
- execute(),执行一个任务,没有返回值。
- submit(),提交一个线程任务,有返回值。
submit(Callable
submit(Runnable task, T result)能通过传入的载体result间接获得线程的返回值。
submit(Runnable task)则是没有返回值的,就算获取它的返回值也是null。
Future.get方法会使取结果的线程进入阻塞状态,知道线程执行完成之后,唤醒取结果的线程,然后返回结果。
关于线程个数建多少个合适
线程的数量并不是建的越多越好、越快,使用多线程就是在正确的场景下通过设置正确个数的线程来最大化程序的运行速度。也就是说,需要根据自身硬件能力和程序的类型来充分利用CPU和IO的利用率。
CPU密集型程序
一个完整请求,I/O操作可以在很短时间内完成, CPU还有很多运算要处理,也就是说 CPU 计算的比例占很大一部分
假设在单核cpu下,创建4个线程执行任务,
由于是单核 CPU,所有线程都在等待 CPU 时间片。按照理想情况来看,四个线程执行的时间总和与一个线程5独自完成是相等的,但是实际上还有四个线程上下文切换的开销。
因此,单核cpu处理cpu密集型程序并不适合使用多线程。
如果是多核CPU,那么每个线程都有CPU来运行,并不会发生等待CPU时间片的情况,也没有线程切换的开销,理论情况来看效率提升了4倍。
所以,CPU密集型程序要最大化利用多线程来提高效率,
理论上来说: 线程数量 = CPU 核数(逻辑) 就可以了。
不过实际情况中一般设置为 CPU 核数(逻辑)+ 1 。因为:
计算(CPU)密集型的线程恰好在某时因为发生一个页错误或者因其他原因而暂停,刚好有一个“额外”的线程,可以确保在这种情况下CPU周期不会中断工作。
——《Java并发编程实战》
IO密集型程序
与 CPU 密集型程序相对,一个完整请求,CPU运算操作完成之后还有很多 I/O 操作要做,也就是说 I/O 操作占比很大部分
程序在进行IO操作时,CPU是处于空闲状态,所以要最大化地利用CPU就不能让其处于空闲状态。
同样假设在单核CPU情况下
每个线程都执行了相同长度的 CPU 耗时和 I/O 耗时,如果将上面的图多画几个周期,CPU操作耗时固定,将 I/O 操作耗时变为 CPU 耗时的 3 倍,此时,CPU又有空闲了,这时就可以新建线程 4,来继续最大化的利用 CPU。
所以,对于IO密集型的程序要最大化利用CPU:
- 线程数 = CPU核心数 * (1/CPU利用率)
- 线程数 = CPU核心数 + [1+(I/O耗时/CPU耗时)]
例如:
假设CPU耗时1,IO操作耗时2,那么单核最佳线程数:
线程数 = 1 + [1+(2/1)] = 4
具体怎么获取IO耗时和CPU耗时或者cpu的利用率,就需要使用一些应用性能管理工具来得到这些信息,比如:
- SkyWalking
- CAT
- zipkin
当然,以上都是理想情况下的理论,实际情况更为复杂,需要仔细研究。