【总结】Java并发基础
该文章为面试精华版,如果是初学者,建议学习专栏:Java并发专栏
文章目录
1. 进程和线程的区别
初期计算机采用串行方式执行任务,需要等待用户输入指令,效率很低;接着计算机采用批处理批量执行用户指令,但是如果有一条指令需要读取大量的数据,此时CPU仍然处于停滞状态,此时就出现了进程。
进程有自己的内存空间,互相之间不干扰,可以保存自己的运行状态可以互相切换,虽然一个CPU依然只能运行一个进程,但当时间片比较小的时候,看起来就像是运行多个程序一样
而线程是为了更进一步的粒度控制,是进程的一个子任务,共享进程的资源,相互切换更加迅速
进程和线程的区别?
- 拥有资源:进程是资源分配的基本单位,但是线程不拥有资源,线程可以访问隶属进程的资源
- 调度:线程是独立调度的基本单位,在同一进程中,线程的切换不会引起进程切换,从一个进程中的线程切换到另一个进程中的线程时,会引起进程切换
- 系统开销:由于创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、I/O 设备等,所付出的开销远大于创建或撤销线程时的开销。类似地,在进行进程切换时,涉及当前执行进程 CPU 环境的保存及新调度进程 CPU 环境的设置,而线程切换时只需保存和设置少量寄存器内容,开销很小
- 通信方面:线程间可以通过直接读写同一进程中的数据进行通信,但是进程通信需要借助 IPC。
Java与进程和线程的关系:
- Java对操作系统提供的功能进行封装,包括进程和线程
- 运行一个程序会产生一个进程,进程包含至少一个线程
- 每个进程对应一个JVM实例,多个线程共享JVM里的堆
- Java采用单线程编程模型,程序会自动创建主线程
- 主线程可以创建子线程,原则上要后于子线程完成执行
2. start() 方法和run()方法的区别
-
Thread中调用start方法默认调用的start0方***创建一个子线程并执行run方法。
-
Thread中run方法,默认调用的是类属性中Runnable接口的run方法,这个体现了策略模式,即执行和策略分离,由用户定义执行的策略
3. JAVA 线程实现/创建方式
有三种使用线程的方法 :
- 实现 Runnable 接口;
- 实现 Callable 接口;
- 继承 Thread 类
实现 Runnable 和 Callable 接口的类只能当做一个可以在线程中运行的任务,不是真正意义上的线程,因此最后还需要通过 Thread 来调用。可以说任务是通过线程驱动从而执行的 。
实现接口比继承 Thread 要好,因为:
- Java 不支持多重继承,因此继承了 Thread 类就无法继承其它类,但是可以实现多个接口;
- 类可能只要求可执行就行,继承整个 Thread 类开销过大。
4. 如何实现处理线程的返回值
- 主线程等待法:利用循环不断的判断,控制和实现很复杂
- 使用Thread的join方法,阻塞主线程
- 使用Callable接口实现:通过FutureTask或者线程池获取
5. 线程的状态
新建(New)
- 创建后尚未启动。
可运行(Runnable)
- 可能正在运行,也可能正在等待 CPU 时间片。
- 包含了操作系统线程状态中的 Running 和 Ready。
阻塞(Blocked)
- 等待获取一个排它锁,如果其线程释放了锁就会结束此状态。
无限期等待(Waiting)
- 等待其它线程显式地唤醒,否则不会被分配 CPU 时间片。
进入方法 | 退出方法 |
---|---|
没有设置 Timeout 参数的 Object.wait() 方法 | Object.notify() / Object.notifyAll() |
没有设置 Timeout 参数的 Thread.join() 方法 | 被调用的线程执行完毕 |
LockSupport.park() 方法 | LockSupport.unpark(Thread) |
限期等待(Timed Waiting)
- 无需等待其它线程显式地唤醒,在一定时间之后会被系统自动唤醒。
- 调用 Thread.sleep() 方法使线程进入限期等待状态时,常常用“使一个线程睡眠”进行描述。
- 调用 Object.wait() 方法使线程进入限期等待或者无限期等待时,常常用“挂起一个线程”进行描述。
- 睡眠和挂起是用来描述行为,而阻塞和等待用来描述状态。
- 阻塞和等待的区别在于,阻塞是被动的,它是在等待获取一个排它锁。而等待是主动的,通过调用 Thread.sleep() 和Object.wait() 等方法进入
进入方法 | 退出方法 |
---|---|
Thread.sleep() 方法 | 时间结束 |
设置了 Timeout 参数的 Object.wait() 方法 | 时间结束 / Object.notify() / Object.notifyAll() |
设置了 Timeout 参数的 Thread.join() 方法 | 时间结束 / 被调用的线程执行完毕 |
LockSupport.parkNanos() 方法 | LockSupport.unpark(Thread) |
LockSupport.parkUntil() 方法 | LockSupport.unpark(Thread) |
死亡(Terminated)
- 可以是线程结束任务之后自己结束,或者产生了异常而结束
6.sleep和wait方法的差异
最主要的本质区别:
- Thread.sleep只会让出CPU ,
不会导致锁行为的改变
- Object.wait不仅让出CPU ,
还会释放已经占有的同步资源锁
7. notify 和 notifyAll的区别
锁池:
假设线程A已经拥有了某个对象(不是类)的锁,而其它线程B、C想要调用这个对象的某个synchronized方法(或者块),由于B、C线程在进入对象的synchronized方法(或者块)之前必须先获得该对象锁的拥有权,而恰巧该对象的锁目前正被线程A所占用,此时B、C线程就会被阻塞,进入一个地方去等待锁的释放,这个地方便是该对象的锁池
等待池:
假设线程A调用了某个对象的wait()方法,线程A就会释放该对象的锁,同时线程A就进入到了该对象的等待池中,进入到等待池中的线程不会去竞争该对象的锁。
notifyAll会让所有处于等待池的线程全部进入锁池去竞争获取锁的机会
notify只会随机选取一个处于等待池中的线程进入锁池去竞争获取锁的机会。
8. 守护线程机制
-
守护线程是程序运行时在后台提供服务的线程,不属于程序中不可或缺的部分。
-
当所有非守护线程结束时,程序也就终止,同时会杀死所有守护线程。
-
main() 属于非守护线程。
-
可以使用 setDaemon() 方法将一个线程设置为守护线程
public static void main(String[] args) {
Thread thread = new Thread(new MyRunnable());
thread.setDaemon(true);
}
9. 中断机制
InterruptedException
通过调用一个线程的 interrupt() 来中断该线程,如果该线程处于阻塞、限期等待或者无限期等待状态,那么就会抛出 InterruptedException,从而提前结束该线程。但是不能中断 I/O 阻塞和 synchronized 锁阻塞。
对于以下代码,在 main() 中启动一个线程之后再中断它,由于线程中调用了 Thread.sleep() 方法,因此会抛出一个InterruptedException,从而提前结束线程,不执行之后的语句。
public class InterruptExample {
private static class MyThread1 extends Thread {
@Override
public void run() {
try {
Thread.sleep(2000);
System.out.println("Thread run");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new MyThread1();
thread1.start();
thread1.interrupt();
System.out.println("Main run");
}
}
interrupted()
如果一个线程的 run() 方法执行一个无限循环,并且没有执行 sleep() 等会抛出 InterruptedException 的操作,那么调用线程的 interrupt() 方法就无法使线程提前结束。
但是调用 interrupt() 方***设置线程的中断标记
,此时调用 interrupted() 方***返回 true。因此可以在循环体中使用 interrupted() 方法来判断线程是否处于中断状态,从而提前结束线程。
public class InterruptExample {
private static class MyThread2 extends Thread {
@Override
public void run() {
while (!interrupted()) {
// ..
}
System.out.println("Thread end");
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread2 = new MyThread2();
thread2.start();
thread2.interrupt();
}
}
10. 保证线程安全的方式
不可变对象
这是一种无锁
的设计模式,因为对于不可变对象没有任何机会去修改这个对象的属性或者引用类型
- final 关键字修饰的基本数据类型
- String
- 枚举类型
- Number 部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。但同为Number 的原子类 AtomicInteger 和 AtomicLong 则是可变的。
- 对于集合类型,可以使用 Collections.unmodifiableXXX() 方法来获取一个不可变的集合,先对原始的集合进行拷贝,需要对集合进行修改的方法都直接抛出异常
互斥同步
synchronize和ReentrantLock
非阻塞同步–CAS
是一种乐观锁,先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。
乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。硬件支持的原子性操作最典型的是:比较并交换(Compare-and-Swap,CAS)。CAS 指令需要有 3 个操作数,分别是内存地址 V、旧的预期值 A 和新值 B。当执行操作时,只有当 V 的值等于 A,才将 V 的值更新为 B。
无同步方案
要保证线程安全,并不是一定就要进行同步。如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。
-
栈封闭
多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。public class StackClosedExample { public void add100() { int cnt = 0; for (int i = 0; i < 100; i++) { cnt++; } System.out.println(cnt); } public static void main(String[] args) { StackClosedExample example = new StackClosedExample(); ExecutorService executorService = Executors.newCachedThreadPool(); executorService.execute(() -> example.add100()); executorService.execute(() -> example.add100()); executorService.shutdown(); } }
-
线程本地存储
可以使用ThreadLocal,实现线程本地存储功能,这样不同线程访问数据时,只能访问属于本线程的数据,不会干扰到其他点线程
-
可重入代码
最简单的理解就是任何变量都是局部变量,保证在被任何一个函数调用时都以同样的方式运行美,也就是说多个线程执行这个代码,只要赋予方法参数相同,方法结果则都一样
11. Java 中用到的线程调度
抢占式调度:
抢占式调度指的是每条线程执行的时间、线程的切换都由系统控制,系统控制指的是在系统某种运行机制下,可能每条线程都分同样的执行时间片,也可能是某些线程执行的时间片较长,甚至某些线程得不到执行的时间片。在这种机制下,一个线程的堵塞不会导致整个进程堵塞。
Java 中线程会按优先级分配 CPU 时间片运行, 且优先级越高越优先执行,但优先级高并不代表能独自占用执行时间片,可能是优先级高得到越多的执行时间片,反之,优先级低的分到的执行时间少但不会分配不到执行时间