https://www.redelego.cn
🍺🍺Java多线程趣味谈
🍋🍋 什么是线程啊?
🐳 🐳 想了解线程,得先了解进程,因为线程是进程的一个单元。你看,我这台电脑同时开了很多个进程,比如说打字用的这个输入法、写作用的这个浏览器,听歌用的这个音乐播放器。这些进程同时可能干几件事,比如说这个音乐播放器,一边滚动着歌词,一边播放着音频。也就是说,在一个进程内部,可能同时运行着多个线程(Thread),每个线程负责着不同的任务。由于每个进程至少要干一件事,所以,一个进程至少有一个线程。
🐳 🐳 在 Java 的程序当中,至少会有一个 main 方法,也就是所谓的主线程。可以同时执行多个线程,执行方式和多个进程是一样的,都是由操作系统决定的。操作系统可以在多个线程之间进行快速地切换,让每个线程交替地运行。切换的时间越短,程序的效率就越高。进程和线程之间的关系可以用一句通俗的话讲,就是"进程是爹妈,管着众多的线程儿女。"
🍋🍋 为什么要用多线程啊?
🐳 🐳 多线程作为一种多任务、并发的工作方式,好处多多。
-
🔘 减少应用程序的响应时间🐲
- 🐳 🐳 对于计算机来说,IO 读写和网络通信相对是比较耗时的任务,如果不使用多线程的话,其他耗时少的任务也必须要等待这些任务结束后才能执行。
-
🔘 充分利用多核CPU的优势🐲
- 🐳 🐳 操作系统可以保证当线程数不大于 CPU 数目时,不同的线程运行于不同的 CPU 上。不过,即便线程数超过了 CPU 数目,操作系统和线程池也会尽最大可能地减少线程切换花费的时间,最大可能地发挥并发的优势,提升程序的性能。
-
🔘 相比于多进程,多线程是一种更"高效"的多任务执行方式🐲
🐳 🐳 对于不同的进程来说,它们具有独立的数据空间,数据之间的共享必须通过"通信"的方式进行。而线程则不需要,同一进程下的线程之间共享数据空间。当然了,如果两个线程存取相同的对象,并且每个线程都调用了一个修改该对象状态的方法,将会带来新的问题。🐳 🐳 什么问题呢?我们来通过下面的示例进行说明。我们创建了一个线程池,通过 for 循环让线程池执行 1000 个线程,每个线程调用了一次
Cmower.addCount()
方法,对 count 值进行加 1 操作,当 1000 个线程执行完毕后,在控制台打印count的值,但几乎不会是我们想要的答案 1000。🐳 🐳 程序在运行过程中,会将运算需要的数据从物理内存中复制一份到 CPU 的高速缓存当中,计算结束之后,再将高速缓存中的数据刷新到物理内存当中。
public class Cmower { public static int count = 0; public static int getCount() { return count; } public static void addCount() { count++; } public static void main(String[] args) { ExecutorService executorService = new ThreadPoolExecutor(10, 1000, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(10)); for (int i = 0; i < 1000; i++) { Runnable r = new Runnable() { @Override public void run() { Cmower.addCount(); } }; executorService.execute(r); } executorService.shutdown(); System.out.println(Cmower.count); } } console> 998、997、998、996、996
🍋🍋 为什么答案不是1000呢?
🐳 🐳 拿count++
来说。当线程执行这个语句时,会先从物理内存中读取 count 的值,然后复制一份到高速缓存当中,CPU 执行指令对 count 进行加 1 操作,再将高速缓存中 count 的最新值刷新到物理内存当中。在多核 CPU 中,每个线程可能运行于不同的 CPU 中,因此每个线程在运行时会有专属的高速缓存。假设线程 A 正在对 count 进行加 1 操作,此时线程 B 的高速缓存中 count 的值仍然是 0 ,进行加 1 操作后 count 的值为 1。最后两个线程把最新值 1 刷新到物理内存中,而不是理想中的 2。这种被多个线程访问的变量被称为共享变量,他们通常需要被保护起来。
🍋🍋 那该怎么保护共享变量呢?
🐳 🐳 针对上例中出现的count,可以按照下面的方式进行改造。
public static AtomicInteger count = new AtomicInteger();
public static int getCount() {
return count.get();
}
public static void addCount() {
count.incrementAndGet();
}
🐳 🐳 使用支持原子操作(即一个操作或者多个操作要么全部执行,并且执行的过程不会被任何因素打断,要么就都不执行)的AtomicInteger
代替基本类型 int。简单分析一下AtomicInteger
类,该类源码中可以看到一个有趣的变量unsafe
。
private static final Unsafe unsafe = Unsafe.getUnsafe();
🐳 🐳 Unsafe
是一个可以执行不安全、容易犯错操作的特殊类。AtomicInteger
使用了Unsafe
的原子操作方法compareAndSwapInt()
对数据进行更新,也就是所谓的 CAS。
public final native boolean compareAndSwapInt(
Object o,
long offset,
int expected,
int x
);
🐳 🐳 参数 o 是要进行 CAS 操作的对象(比如说 count),参数 offset 是内存位置,参数 expected 是期望的值,参数 x 是需要更新到的值。一般的同步方法会从地址 offset 读取值 A,执行一些计算后获得新值 B,然后使用 CAS 将 offset 的值从 A 改为 B。如果 offset 处的值尚未同时更改,则 CAS 操作成功。CAS 允许执行"读-修改-写"的操作,而无需担心其他线程同时修改了变量,因为如果其他线程修改变量,那么 CAS 会检测它(并失败),算法可以对该操作重新计算。AtomicInteger
类的源码中还有一个值得注意的变量value
。
private volatile int value;
🐳 🐳 value
使用了关键字volatile
来保证可见性——当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。当一个共享变量被volatile
修饰后,它被修改后的值会立即更新到物理内存中,当有其他线程需要读取时,会去物理内存中读取新值。而没有被volatile
修饰的共享变量不能保证可见性,因为不确定这些变量会在什么时候被写入物理内存中,当其他线程去读取时,读到的可能还是原来的旧值。特别需要注意的是,volatile
关键字只保证变量的可见性,不能保证原子性。