并行程序基础
本文为《实战java高并发程序设计》电子笔记,供个人查阅及装逼,不具有参考性。
https://legacy.gitbook.com/book/jiapengcai/effective-java/details
1.2 几个重要的概念
- 并发偏重于多个任务交替执行,而多个任务之间有可能还是串行的。而并行是真正意义上的“同时执行
- 临界区
- 阻塞(Blocking)和非阻塞(Non-Blocking)
阻塞:一个线程占用了临界区的资源,其他所有需要这个资源的线程就必须在这个临界区中进行等待。等待会导致线程挂起,这种情况就是阻塞。
非阻塞:没有一个线程可以妨碍其他线程执行。所有的线程都会尝试不断前向执行 - 死锁(Deadlock)、饥饿(Starvation)和活锁(Livelock)
1.3 并发级别
由于临界区的存在,多线程之间的并发必须受到控制。根据控制并发的策略,我们可以把并发的级别进行分类,大致上可以分为阻塞、无饥饿、无障碍、无锁、无等待几种。
- 阻塞(Blocking)
在其他线程释放资源之前,当前线程无法继续执行。当我们使用synchronized关键字,或者重入锁时,我们得到的就是阻塞的线程。
无论是synchronized或者重入锁,都会试图在执行后续代码前,得到临界区的锁,如果得不到,线程就会被挂起等待,直到占有了所需资源为止。
- 无饥饿(Starvation-Free)
不同线程的优先级相等 - 无障碍(Obstruction-Free)
一种最弱的非阻塞调度。两个线程无障碍运行,他们不会因为临界区的问题导致一方挂起。但数据出现异常,则立即回滚自己的修改,确保数据正确。 - 无锁(Lock-Free)
无锁的并行都是无障碍的。在无锁的情况下,所有的线程都能尝试对临界区进行访问,但不同的是,无锁的并发保证必然有一个线程能够在有限步内完成操作离开临界区。 - 无等待(Wait-Free)
无锁只要求有一个线程可以在有限步内完成操作,而无等待则在无锁的基础上更进一步进行扩展。它要求所有的线程都必须在有限步内完成,这样就不会引起饥饿问题。
一种典型的无等待结构就是RCU(Read-Copy-Update)。它的基本思想是,对数据的读可以不加控制。因此,所有的读线程都是无等待的,它们既不会被锁定等待也不会引起任何冲突。但在写数据的时候,先取得原始数据的副本,接着只修改副本数据(这就是为什么读可以不加控制),修改完成后,在合适的时机回写数据。
1.4关于并行的两个重要定律
1.5 JMM
我们需要在深入了解并行机制的前提下,再定义一种规则,保证多个线程间可以有效地、正确地协同工作。而JMM也就是为此而生的。
JMM的关键技术点都是围绕着多线程的原子性、可见性和有序性来建立的
- 原子性(Atomicity)
原子性是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。 - 可见性(Visibility)
出现一个线程的修改不会立即被其他线程察觉的情况
2.2 线程的基本操作
1.新建线程
通过继承Thread类或实现Runnable接口
start()方法就新建一个线程并让这个线程执行run()方法
2.停止线程
一般无需手动关闭线程
Thread.stop()方法在结束线程时,会直接终止线程,并且会立即释放这个线程所持有的锁。而这些锁恰恰是用来维持对象一致性的。故stop方法不使用
3.线程中断
public void Thread.interrupt() // 中断线程
public boolean Thread.isInterrupted() // 判断是否被中断
public static boolean Thread.interrupted() // 判断是否被中断,并清除当前中断状态
- Thead.sleep()方法:让当前线程休眠若干时间
Thread.sleep()方法会让当前线程休眠若干时间,它会抛出一个InterruptedException中断异常。InterruptedException不是运行时异常,也就是说程序必须捕获并且处理它,当线程在sleep()休眠时,如果被中断,这个异常就会产生
01 public static void main(String[] args) throws InterruptedException {
02 Thread t1=new Thread(){
03 @Override
04 public void run(){
05 while(true){
06 if(Thread.currentThread().isInterrupted()){
07 System.out.println("Interruted!");
08 break;
09 }
10 try {
11 Thread.sleep(2000);
12 } catch (InterruptedException e) {
13 System.out.println("Interruted When Sleep");
14 //设置中断状态
15 Thread.currentThread().interrupt();
16 }
17 Thread.yield();
18 }
19 }
20 };
21 t1.start();
22 Thread.sleep(2000);
23 t1.interrupt();
24 }
Thread.sleep()方法由于中断而抛出异常,此时,它会清除中断标记,如果不加处理,那么在下一次循环开始时,就无法捕获这个中断,故在异常处理中,再次设置中断标记位。
4.等待(wait)和通知(notify)
这两个方法并不是在Thread类中的,而是输出Object类。这也意味着任何对象都可以调用这两个方法。
线程A中,调用了obj.wait()方法,那么线程A就会停止继续执行,而转为等待状态。线程A会一直等到其他线程调用了obj.notify()方法为止。这时,obj对象就俨然成为多个线程之间的有效通信手段。
注意:
必须在包含synchronzied的语句中才可以调用 wait方法
6.等待线程结束(join)和谦让(yield)
public final void join() throws InterruptedException
public final synchronized void join(long millis) throws InterruptedException
第一个join()方法表示无限等待,它会一直阻塞当前线程,直到目标线程执行完毕。第二个方法给出了一个最大等待时间,超过最大时间线程就继续执行。
public volatile static int i=0;
public static class AddThread extends Thread{
@Override
public void run() {
for(i=0;i<10000000;i++);
}
}
public static void main(String[] args) throws InterruptedException {
AddThread at=new AddThread();
at.start();
at.join();
//主线程等待子线程执行后再执行
System.out.println(i);
}
join()的本质是让调用线程wait()在当前线程对象实例
public static native void yield();
//让出当前占用cpu,但接下来会继续参与抢夺
2.3volatile与Java内存模型(JMM)
当你用volatile去申明一个变量时,就等于告诉了虚拟机,这个变量极有可能会被某些程序或者线程修改。为了确保这个变量被修改后,应用程序范围内的所有线程都能够“看到”这个改动,虚拟机就必须采用一些特殊的手段,保证这个变量的可见性等特点。
- volatile并不能代替锁,它也无法保证一些复合操作的原子性。比如下面的例子,通过volatile是无法保证i++的原子性操作的:
01 static volatile int i=0;
02 public static class PlusTask implements Runnable{
03 @Override
04 public void run() {
05 for(int k=0;k<10000;k++)
06 i++;
07 }
08 }
09
10 public static void main(String[] args) throws InterruptedException {
11 Thread[] threads=new Thread[10];
12 for(int i=0;i<10;i++){
13 threads[i]=new Thread(new PlusTask());
14 threads[i].start();
15 }
16 for(int i=0;i<10;i++){
17 threads[i].join();
18 }
19
20 System.out.println(i);
21 }
执行上述代码,如果第6行i++是原子性的,那么最终的值应该是100000(10个线程各累加10000次)。但实际上,上述代码的输出总是会小于100000。
- volatile能保证数据的可见性和有序性
2.6 线程优先级
public final static int MIN_PRIORITY = 1;
public final static int NORM_PRIORITY = 5;
public final static int MAX_PRIORITY = 10;
2.7 线程安全的概念与synchronized
volatile并不能真正的保证线程安全。它只能确保一个线程修改了数据后,其他线程能够看到这个改动。但当两个线程同时修改某一个数据时,却依然会产生冲突
01 public class AccountingVol implements Runnable{
02 static AccountingVol instance=new AccountingVol();
03 static volatile int i=0;
04 public static void increase(){
05 i++;
06 }
07 @Override
08 public void run() {
09 for(int j=0;j<10000000;j++){
10 increase();
11 }
12 }
13 public static void main(String[] args) throws InterruptedException {
14 Thread t1=new Thread(instance);
15 Thread t2=new Thread(instance);
16 t1.start();t2.start();
17 t1.join();t2.join();
18 System.out.println(i);
19 }
20 }
很多时候,i的最终值会小于20000000。这就是因为两个线程同时对i进行写入时,其中一个线程的结果会覆盖另外一个(虽然这个时候i被声明为volatile变量)。线程1和线程2同时读取i为0,并各自计算得到i=1,并先后写入这个结果,因此,虽然i++被执行了2次,但是实际i的值只增加了1。
要从根本上解决这个问题,我们就必须保证多个线程在对i进行操作时完全同步,使用关键字synchronized来实现这个功能
关键字synchronized的作用是实现线程间的同步。它的工作是对同步的代码加锁,使得每一次,只能有一个线程进入同步块,从而保证线程间的安全性
关键字synchronized可以有多种用法。这里做一个简单的整理。
- 指定加锁对象:对给定对象加锁,进入同步代码前要获得给定对象的锁。
- 直接作用于实例方法:相当于对当前实例加锁,进入同步代码前要获得当前实例的锁。
- 直接作用于静态方法:相当于对当前类加锁,进入同步代码前要获得当前类的锁。
01 public class AccountingSync2 implements Runnable{
02 static AccountingSync2 instance=new AccountingSync2();
03 static int i=0;
04 public synchronized void increase(){
05 i++;
06 }
07 @Override
08 public void run() {
09 for(int j=0;j<10000000;j++){
10 increase();
11 }
12 }
13 public static void main(String[] args) throws InterruptedException {
14 Thread t1=new Thread(instance);
15 Thread t2=new Thread(instance);
16 t1.start();t2.start();
17 t1.join();t2.join();
18 System.out.println(i);
19 }
20 }
这两个线程都指向同一个Runnable接口实例(instance对象),这样才能保证两个线程在工作时,能够关注到同一个对象锁上去,从而保证线程安全。
一个错误的示例
01 public class AccountingSyncBad implements Runnable{
02 static int i=0;
03 public synchronized void increase(){
04 i++;
05 }
06 @Override
07 public void run() {
08 for(int j=0;j<10000000;j++){
09 increase();
10 }
11 }
12 public static void main(String[] args) throws InterruptedException {
13 Thread t1=new Thread(new AccountingSyncBad());
14 Thread t2=new Thread(new AccountingSyncBad());
15 t1.start();t2.start();
16 t1.join();t2.join();
17 System.out.println(i);
18 }
19 }
但我们只要简单地修改上述代码,就能使其正确执行。那就是使用synchronized的第三种用法,将其作用于静态方法。将increase()方法修改如下:
这样,即使两个线程指向不同的Runnable对象,但由于方法块需要请求的是当前类的锁,而非当前实例,因此,线程间还是可以正确同步。
public static synchronized void increase(){
i++;
}
被synchronized限制的多个线程是串行执行的
2.8 程序中的幽灵:隐蔽的错误
- 并发下的ArrayList
注意:改进的方法很简单,使用线程安全的Vector代替ArrayList即可。
- 并发下诡异的HashMap
最简单的解决方案就是使用ConcurrentHashMap代替HashMap。
- 错误的加锁
比如加在不可变的int上