阅读 116

JAVA并行程序基础 学习笔记

JAVA并行程序基础 学习笔记

学习资料:《深入理解计算机系统》,《Java高并发程序设计》,《Java并发编程实战》,《Java并发编程的艺术》,《Java核心技术卷1》多线程一章,极客时间王宝令的Java并发编程实战课程…


以下大部分阐述来自上述书籍与课程中个人认为很重要的部分,也有部分心得体会。后续还会更新并发包,并发算法等各种并发相关笔记,点点关注不迷路!ヽ(✿゚▽゚)ノ


一.概念辨析

1.进程 & 线程

进程:操作系统对一个正在运行的而程序的一种抽象。在一个系统上可以同时运行多个进程,而每个进程都好像在独占地使用硬件。(进程是线程的容器)而并发运行,则是说一个进程的指令和另一个进程的指令是交错执行的。这种机制叫做上下文切换。进程可以通过多核处理器来并行!


举例:当你双击一个exe程序时,这个.exe文件的指令就会被加载,那么你就能得到一个关于这个程序的进程。进程是活的,是正在被执行的,你可以通过任务管理器看到你电脑正在执行的进程。




线程:一个进程由将多个称为线程的执行单元组成,每个线程都运行在进程的上下文中,并共享同样的代码和全局数据。因为多线程之间比多进程之间更容易共享数据,也因为线程一般来说都比进程更高效。线程可以实现宏观上的“同时”执行,实际上是快速切换线程来达到几乎同时执行的效果。也可以称线程为轻量级进程,它是程序执行的最小单位。


多进程与多线程的本质区别:每个进程都拥有自己的一整套变量,而线程则共享数据。多线程比多进程开销小得多。(当然也要看具体的操作系统,Windows和Linux是不同的,Windows开进程开销大,Linux开线程开销大,因此 Windows 多线程学习重点是要大量面对资源争抢与同步方面的问题,Linux 下的学习重点大家要学习进程间通讯的方法)


2.同步 & 异步

同步 Synchronous方法调用一旦开始,调用者必须等到方法调用返回后,才能继续后续的行为。


异步 Asynchronous方法调用更像一个消息传递,一旦开始,方法调用就会立即返回,调用者就可以继续后续的操作。


区别:同步就是要等到整个流程全部结束,而异步只是传递一个接下来要去做什么什么事情的消息,然后就会去干其他事。


3.并行 & 并发

并行 Parallel:多个cpu实例或者多台机器同时执行一段处理逻辑,是真正的同时。


并行二定律


1.Amdahl定律 加速比=1/[F+(1-F)/n](n为处理器储量,F为并行比例) 由此可见,为了提高系统的速度,仅仅增加CPU处理器数量不一定能起到有效的作用。需要根本上修改程序的串行行为,提高系统内并行化的模块比重。


2.Gustafson定律 加速比=n-F(n-1) 如果串行化比例很小,并行化比例很大,那么加速比就是处理起个数,只要不断累加处理起,就能获得更快的速度


并发 ConCurrent:通过cpu调度算法,让用户看上去同时执行,实际上从cpu操作层面不是真正的同时。并发往往在场景中有公用的资源,那么针对这个公用的资源往往产生瓶颈,我们会用TPS或者QPS来反应这个系统的处理能力。


区别:并行就是同时进行,并发则是一个做一点,然后另一个再做一点。


4.死锁、饥饿、活锁

死锁:所有线程互相占用了对方的锁,导致所有线程挂起。


饥饿:某些线程因为某些原因(优先级过低)无法获得所需的资源,导致无法运行。


活锁:两个线程互相释放资源给对方,从而导致没有一个线程可以同时拿到所有资源正常执行。(出电梯时,和一个进电梯的人互相谦让,导致进电梯的人进不了,出电梯的人出不去)


5.锁 & 监视器

锁为实现监视器提供必要的支持。

1

锁是对象内存堆中头部的一部分数据。JVM中的每个对象都有一个锁(或互斥锁),任何程序都可以使用它来协调对对象的多线程访问。如果任何线程想要访问该对象的实例变量,那么线程必须拥有该对象的锁(在锁内存区域设置一些标志)。所有其他的线程试图访问该对象的变量必须等到拥有该对象的锁有的线程释放锁(改变标记)。


一旦线程拥有一个锁,它可以多次请求相同的锁,但是在其他线程能够使用这个对象之前必须释放相同数量的锁。如果一个线程请求一个对象的锁三次,如果别的线程想拥有该对象的锁,那么之前线程需要 “释放”三次锁。


1) 锁用来保护代码片段,任何时刻只能有一个线程执行被保护的代码。

2) 锁可以管理试图进入被保护代码的线程

3) 锁可以拥有一个或者多个相关的条件对象

4) 每个条件对象管理那些已经进入被保护的代码段,但还不能运行的线程

1

2

3

4

监视器是一中同步结构,它允许线程同时互斥(使用锁)和协作,即使用等待集(wait-set)使线程等待某些条件为真的能力。


他们是应用于同步问题的人工线程调度工具。讲其本质,首先就要明确monitor的概念,Java中的每个对象都有一个监视器,来监测并发代码的重入。在非多线程编码时该监视器不发挥作用,反之如果在synchronized 范围内,监视器发挥作用。


wait/notify/notifyAll必须存在于synchronized块中。并且,这三个关键字针对的是同一个监视器(某对象的监视器)。这意味着wait之后,其他线程可以进入同步块执行。


当某代码并不持有监视器的使用权时,去wait或notify,会抛出java.lang.IllegalMonitorStateException。也包括在synchronized块中去调用另一个对象的wait/notify,因为不同对象的监视器不同,同样会抛出此异常。


二.并发编程的核心问题

分工指的是如何高效地拆解任务并分配给线程。类似于“烧水泡茶”问题。


同步指的是线程之间如何协作。当某个条件不满足时,线程需要等待,当某个条件满足时,线程需要被唤醒执行。


互斥则是保证同一时刻只允许一个线程访问共享资源。也就是所谓的“线程安全”。核心技术为“锁”。


三.并发的隐患

1.缓存导致的可见性问题

可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。


volatiole变量的写先于读发生,保证了它的可见性。


多核时代,每颗 CPU 都有自己的缓存,当多个线程在不同的 CPU 上执行时,这些线程操作的位置是不同的 CPU缓存,他们之间不具有可见性。


2.线程切换带来的原子性问题

原子性:一个或者多个操作在 CPU 执行的过程中不被中断的特性。


举例


例1:同时向一个变量发起两次修改请求,可能会导致变量修改失败。


补充 若要对一个变量进行操作,至少需要三条 CPU 指令:


指令 1:把变量从内存加载到 CPU 的寄存器;

指令 2:在寄存器中修改变量;

指令 3:将结果写入内存/缓存。

1

2

3

4

5

在线程A将结果写入内存之前,线程B可能已经读入了初始的变量值。 然后线程A将修改结果写入内存后,线程B也将结果写入内存。这会导致线程A的修改被完全覆盖,因为线程B的初始值读入的是线程A修改之前的变量值。


例2:在32位的系统上,读写long(64位数据)


使用双线程同时对long型数据进行写入或读取。

如果新建多个线程同时改变long型数据的值,最后的值可能是乱码。因为是并行读入的,所以可能读的时候错位了。


3.编译带来的有序性

有序性:程序按照代码的先后顺序执行。 编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6”,有时候会导致意想不到的bug。

(指令重排对于CPU处理性能是十分必要的)


举例


在 Java 领域一个经典的案例就是利用双重检查创建单例对象,例如下面的代码:在获取实例 getInstance() 的方法中,我们首先判断 instance 是否为空,如果为空,则锁定 Singleton.class 并再次检查 instance 是否为空,如果还为空则创建 Singleton 的一个实例。


public class Singleton {

  static Singleton instance;

  static Singleton getInstance(){

    if (instance == null) {

      synchronized(Singleton.class) {

        if (instance == null)

          instance = new Singleton();

        }

    }

    return instance;

  }

}

1

2

3

4

5

6

7

8

9

10

11

12

假设有两个线程 A、B 同时调用 getInstance() 方法,他们会同时发现 instance == null ,于是同时对Singleton.class 加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程B); 线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创建过 Singleton实例了,所以线程 B 不会再创建一个 Singleton 实例。


这看上去一切都很完美,无懈可击,但实际上这个 getInstance() 方法并不完美。问题出在哪里呢?出在 new 操作上,我们以为的new 操作应该是:


分配一块内存 M;

在内存 M 上初始化 Singleton 对象;

然后 M 的地址赋值给 instance 变量。

1

2

3

但是实际上优化后的执行路径却是这样的:


分配一块内存 M;

将 M 的地址赋值给 instance 变量;

最后在内存 M 上初始化 Singleton 对象。

1

2

3

优化后会导致什么问题呢?


我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程B 上; 如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance。 而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。




解决方案1:将Instance声明为volatitle,前面的重排序在多线程环境中将会被禁止


public class Singleton {

private static volatile Singleton sInstance;

public static Singleton getInstance() {

        if (sInstance == null) {

                synchronized (Singleton.class) {

                        if (sInstance == null) {

                           sInstance = new Singleton();

                            }

                        }

                    }

                    return sInstance;

            }

private Singleton() {}

}

1

2

3

4

5

6

7

8

9

10

11

12

13

14

解决方案2:静态内部类


public class Singleton {

    private Singleton(){};

    private static class Inner{

        private static Singleton SINGLETION=new Singleton();

    }

    public static Singleton getInstance(){

        return Inner.SINGLETION;

    }

}  

1

2

3

4

5

6

7

8

9

静态内部类不会随着外部类的初始化而初始化,他是要单独去加载和初始化的,当第一次执行getInstance方法时,Inner类会被初始化。


静态对象SINGLETION的初始化在Inner类初始化阶段进行,类初始化阶段即虚拟机执行类构造器()方法的过程。


虚拟机会保证一个类的()方法在多线程环境下被正确的加锁和同步,如果多个线程同时初始化一个类,只会有一个线程执行这个类的()方法,其它线程都会阻塞等待。


四.解决原子性,可见性和有序性

我们已经知道,导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性、有序性最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,我们程序的性能可就堪忧了。


合理的方案应该是按需禁用缓存以及编译优化。那么,如何做到“按需禁用”呢?对于并发程序,何时禁用缓存以及编译优化只有程序员知道,那所谓“按需禁用”其实就是指按照程序员的要求来禁用。所以,为了解决可见性和有序性问题,只需要提供给程序员按需禁用缓存和编译优化的方法即可。


Java 内存模型是个很复杂的规范,本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则。


volatile

volatile 关键字并不是 Java 语言的特产,古老的 C 语言里也有,它最原始的意义就是禁用 CPU 缓存。当你使用了这个变量,就等于告诉了虚拟机,这个变量极有可能会被某些程序或线程修改。为了确保这个变量被修改后,应用程序范围内的所有线程都能看到这个改动,虚拟机必须得进行一些特殊的手段。


多线程的内存模型:main memory(主存)、working memory(线程栈),在处理数据时,线程会把值从主存load到本地栈,完成操作后再save回去(volatile关键词的作用:每次针对该变量的操作都激发一次load and save)。


针对多线程使用的变量如果不是volatile或者final修饰的,很有可能产生不可预知的结果(另一个线程修改了这个值,但是之后在某线程看到的是修改之前的值)。其实道理上讲同一实例的同一属性本身只有一个副本。但是多线程是会缓存值的,本质上,volatile就是不去缓存,直接取值。在线程安全的情况下加volatile会牺牲性能。但相比较synchronized和锁,性能更佳。与普通变量相比,就是写入的操作慢一点,因为会加入许多内存屏障指令。


内存屏障(memory barrier)是一个CPU指令。基本上,它是这样一条指令:

a) 确保一些特定操作执行的顺序;

b) 影响一些数据的可见性(可能是某些指令执行后的结果)。编译器和CPU可以在保证输出结果一样的情况下对指令重排序,使性能得到优化。插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。内存屏障另一个作用是强制更新一次不同CPU的缓存。例如,一个写屏障会把这个屏障前写入的数据刷新到缓存,这样任何试图读取该数据的线程将得到最新值,而不用考虑到底是被哪个cpu核心或者哪颗CPU执行的。


内存屏障(memory barrier)和volatile什么关系?上面的虚拟机指令里面有提到,如果你的字段是volatile,Java内存模型将在写操作后插入一个写屏障指令,在读操作前插入一个读屏障指令。这意味着如果你对一个volatile字段进行写操作,你必须知道:1、一旦你完成写入,任何访问这个字段的线程将会得到最新的值。2、在你写入前,会保证所有之前发生的事已经发生,并且任何更新过的数据值也是可见的,因为内存屏障会把之前的写入值都刷新到缓存。


1.原 子 性


volatile并不能直接替代锁的作用,他只能保证可见性和有序性,但volatile保证不了原子性操作!!!!!


修改一个变量值的JVM指令:


mov    0xc(%r10),%r8d ; Load

inc    %r8d           ; Increment

mov    %r8d,0xc(%r10) ; Store

lock addl $0x0,(%rsp) ; StoreLoad Barrier

1

2

3

4

从Load到store到内存屏障,一共4步,其中最后一步jvm让这个最新的变量的值在所有线程可见,也就是最后一步让所有的CPU内核都获得了最新的值,但中间的几步(从Load到Store)是不安全的,中间如果其他的CPU修改了值将会丢失。


最简单的一个例子是调用多次一个线程进行i++操作:


一个变量i被volatile修饰,两个线程想对这个变量修改,都对其进行自增操作也就是i++,i++的过程可以分为三步,首先获取i的值,其次对i的值进行加1,最后将得到的新值写回到缓存中。

线程A首先得到了i的初始值100,但是还没来得及修改,就阻塞了,这时线程B开始了,它也得到了i的值,由于i的值未被修改,即使是被volatile修饰,主存的变量还没变化,那么线程B得到的值也是100,之后对其进行加1操作,得到101后,将新值写入到缓存中,再刷入主存中。根据可见性的原则,这个主存的值可以被其他线程可见。

问题来了,线程A已经读取到了i的值为100,也就是说读取的这个原子操作已经结束了,所以这个可见性来的有点晚,线程A阻塞结束后,继续将100这个值加1,得到101,再将值写到缓存,最后刷入主存,所以即便是volatile具有可见性,也不能保证对它修饰的变量具有原子性。


除此之外,还有一个很重要的点。在JAVA中,Integer 属于不变对象,也就是说,如果你要修改一个值为1的Integer对象,实际上是新建一个值为2的Interger对象,i=Integer.valueOf(i.intValue()+1),所以真正要达到修改它的值的目的,必须得用synchronized并传入一个自加线程相同的锁。


2.可见性 & 顺序性


为了解决volatile所带来的可能的可见性问题,jdk1.5以后添加了Happens-Before 规则,它规定了哪些指令不能重排。


Happens-Before

1)程序顺序原则:一个线程内保证语义的串行性。

2)volatile规则:volatile变量的写先于读发生,这保证了它的可见性 。

3)传递性:A先于B,B先于C,那么A必然先于C。

4)管程中锁规则:解锁必须在加锁前。

5)线程的start()方法先于它的每一个动作。

6)线程的所有操作先于线程的终结(Thread.join())。

7)线程的中断先于被中断线程的代码。

8)对象的构造函数的执行、结束先于finalize()方法。


1)程序顺序原则


符合单线程里面的思维:程序前面对某个变量的修改一定是对后续操作可见的


2 & 3)volatile 变量规则+传递性

举例:


如果线程 B 读到了“v=true”,那么线程 A 设置的“x=42”对线程 B 是可见的。也就是说,线程 B 能读到 x = 42 。


4)管程中锁的规则


管程:是一种通用的同步原语,在 Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现。


举例


在多线程环境下,synchronized块中的方法获取了lock实例的monitor,如果实例相同,那么只有一个线程能执行该块内容


public class Thread1 implements Runnable {

   Object lock;

   public void run() {  

       synchronized(lock){// 此处自动加锁

         ..do something

       }

   }// 此处自动解锁

}

1

2

3

4

5

6

7

8

也可以直接用于方法: 相当于上面代码中用lock来锁定的效果,实际获取的是Thread1类的monitor。更进一步,如果修饰的是static方法,则锁定该类所有实例。


public class Thread1 implements Runnable {

   public synchronized void run() {  

        ..do something

   }

}

1

2

3

4

5

5)线程 start() 规则


如果线程 A 调用线程 B 的 start() 方法(即在线程 A 中启动线程 B),那么该 start() 操作 Happens-Before 于线程 B 中的任意操作。


6)线程 join() 规则


如果在线程 A 中,调用线程 B 的 join() 并成功返回,那么线程 B 中的任意操作 Happens-Before 于该 join() 操作的返回


synchronized

关键字synchronized的作用是实现线程之间的同步,他的工作室对同步的代码枷锁,使得每一次,只能有一个线程进入同步块,从而保证线程之间的安全性。


三种使用方法:

1.指定加锁对象

2.直接作用于实例方法

3.直接作用于静态方法


实例方法要求thread指向的接口是同一个,

而静态方法则不需要。


五.多线程

1.线程的状态

打开JAVA的Thread类里的State枚举类,可以看到


public enum State {

NEW,

RUNNABLE,

BLOCKED,

WAITING,

TIMED_WAITING,

TERMINATED;

}

1

2

3

4

5

6

7

8


线程在Running的过程中可能会遇到阻塞(Blocked)情况


1.调用join()和sleep()方法,sleep()时间结束或被打断,join()中断,IO完成都会回到Runnable状态,等待JVM的调度。


2.调用wait(),使该线程处于等待池,直到notify()/notifyAll(),线程被唤醒被放到锁定池,释放同步锁使线程回到Runnable


3.对Running状态的线程加同步锁(Synchronized)使其进入锁定池,同步锁被释放进入(Runnable)。


WAITING和TIMED_WAITING都是等待状态,区别是通过wait()进入WAITING等待是notify()或者notifyAll(),通过join()进入则等待目标线程结束;TIMED_WAITING是在进行一个有时限的等待。


2.线程的基本操作(JAVA)

1)新建一个线程

Thread t1 = new Thread(){

            @Override

            public void run() {

                ..do something

            }

        };

t1.start();

1

2

3

4

5

6

7

一般的类要实现线程,可以继承Thread类,当然也可以使用Runnable接口。最常使用的还是用正则表达式重写run函数。

构造方法:public Thread(Runnable targert)


2)终止线程

不建议用已经被废弃的stop() ,因为它会自动释放被终止对象的锁。

推荐使用的是


volatile boolean stopme =false;

public void stopMe(){

stopme = true;

}

...

while(true){

if(stopme){

break;

}

. . do something

}

1

2

3

4

5

6

7

8

9

10

11

3)线程中断

三个方法

1)public void Thread.interrupt()

通知目标线程中断,也就是设置中断标志位。

2)public boolean Thread.isInterrupted()

判断当前线程是否被中断,也就是检查中断标志位

3)public static boolean Thread.interrupted()

判断是否被中断,并清除当前中断标志位

1

2

3

4

5

6

7

Thread.sleep()方法会让当前线程休眠若干时间,它会抛出InterruptedException中断异常。此时,它会清除中断标记。所以我们应该在异常处理中再次将其中断。


try{

Thread.sleep(2000);

} catch (InterruptedException e) {

System.ot,println("xxx");

Thread.currentThread().interrupt();

}

1

2

3

4

5

6

7

4)等待和通知

wait()和notify()是Object类的方法。

在一个实例对象上,在一个synchronzied语句中,调用wait()方法后,当前线程就会在这个对象上等待,并释放锁。直到有其他线程执行了notify()/notifyAll()。使用notify()/notifyAll()后,会唤醒处于等待状态的线程,是他们的状态变为Runnable。


挂起(suspend)和继续执行(resume)已被废弃。


5)等待线程结束和谦让

join()是等待方法,该方法将一直等待到它调用的线程终止。


Join方法实现是通过wait()在当前线程对象实例上。 当main线程调用t.join()时候,main线程会获得线程对象t的锁(wait 意味着拿到该对象的锁),调用该对象的wait(等待时间),直到该对象唤醒main线程 ,比如退出后。这就意味着main线程调用t.join时,必须能够拿到线程t对象的锁。


用途:在很多情况下,主线程生成并起动了子线程,如果子线程里要进行大量的耗时的运算,主线程往往将于子线程之前结束,但是如果主线程处理完其他的事务后,需要用到子线程的处理结果,也就是主线程需要等待子线程执行完成之后再结束,这个时候就要用到join()方法了。


不要在应用程序中,在Thread对象实例上使用类似wait()方法或者notify()方法等,可能会和API互相影响。


yield()是谦让方法,它会让当前线程让出CPU,但线程还是会进行CPU资源的争夺。如果你觉得一个线程不是非常重要,又害怕占用过多资源,可以使用它来给其他线程更多的工作机会。


6)线程组

如果线程数量很多,而且功能分配明确,可以将相同功能的线程放置在同一个线程组里。

activeCount()返回活动线程总数(不精确的)

list()返回线程组中所有线程的信息。


public class Test implements Runnable{

    public static void main(String[] args) {

        ThreadGroup tg = new ThreadGroup("GroupName");

        Thread t1 = new Thread(tg,new Test(),"ThreadName1");

        Thread t2 = new Thread(tg,new Test(),"ThreadName2");

        t1.start();

        t2.start();

        System.out.println(tg.activeCount());

        tg.list();

    }


    @Override

    public void run() {

        String groupAndName = Thread.currentThread().getThreadGroup().getName()+" - "+Thread.currentThread().getName();

        while(true){

            System.out.println("I am "+groupAndName);

            try {

                Thread.sleep(3000);

            } catch (InterruptedException e) {

                e.printStackTrace();

            }

        }

    }

}

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

7.守护线程

守护线程是系统的守护者,他会在后台完成一些系统性的服务。如果应用内只有守护线程,那么JAVA虚拟机就会退出。


t.setDaemon(true);

t.start();

1

2

设置守护线程必须得在start之前,不然该线程会被当作用户线程,永远无法停止。


8.线程优先级

内置三个优先级,数字越大优先级越高。【1,10】

高优先级的线程 倾向于 更快地完成。


public final static int MIN_PRIORITY = 1;

public final static int NORM_PRIORITY = 5;

public final static int MAX_PRIORITY = 10;

————————————————

版权声明:本文为CSDN博主「九幽孤翎」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。

原文链接:https://blog.csdn.net/HDUCheater/article/details/114728732


文章分类
后端
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐