0%

Java并发编程系列-(1) 并发编程基础

1.并发编程基础

1.1 基本概念

CPU核心与线程数关系

Java中通过多线程的手段来实现并发,对于单处理器机器上来讲,宏观上的多线程并行执行是通过CPU的调度来实现的,微观上CPU在某个时刻只会运行一个线程。事实上,如果这些任务不存在阻塞,也就是程序中的某个任务因为该程序控制范围之外的某些条件(通常是I/O)而导致不能继续执行,由于在任务之间切换会产生开销,因此并行的效率可能没有顺序执行的效率高,并行也就没有意义。

一般来讲,CPU核心数和线程数的关系为核心数:线程数=1:1;但是如果使用了超线程技术,可以达到1:2甚至更多。

CPU调度方式

CPU采用时间片轮转机制,来调度不同的线程运行,又称RR调度,注意这样会导致上下文切换。如果线程数目过大,可能产生较大的线程切换开销。

线程和进程

进程:进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,是系统进行资源分配和调度的一个独立单位。(包括程序段,相关数据段,和进程控制块PCB)

线程:线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源.

关系:一个线程可以创建和撤销另一个线程;同一个进程中的多个线程之间可以并发执行.相对进程而言,线程是一个更加接近于执行体的概念,它可以与同进程中的其他线程共享数据,但拥有自己的栈空间,拥有独立的执行序列。
  
区别:主要差别在于它们是不同的操作系统资源管理方式。进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它进程产生影响,而线程只是一个进程中的不同执行路径。线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间,一个线程死掉就等于整个进程死掉,所以多进程的程序要比多线程的程序健壮,但在进程切换时,耗费资源较大,效率要差一些。但对于一些要求同时进行并且又要共享某些变量的并发操作,只能用线程,不能用进程。

优缺点:线程和进程在使用上各有优缺点:线程执行开销小,但不利于资源的管理和保护;而进程正相反。同时,线程适合于在SMP机器上运行,而进程则可以跨机器迁移。

并行和并发

并行:同一时刻,可以同时处理事情的能力。

并发:与单位时间相关,在单位时间内可以处理事情的能力。

高并发编程的意义和注意事项

意义和好处:充分利用cpu的资源、加快用户响应的时间,程序模块化,异步化
问题。

缺陷和注意事项:

  • 线程共享资源,存在冲突;
  • 容易导致死锁;
  • 启用太多的线程,会产生巨大的CPU和内存开销,就有搞垮机器的可能。

1.2 线程的启动与停止

线程的3种启动方式

Java里线程有3种启动方式,或者换句话说有3种方式可以实现多线程,分别是:

  • 继承Thread类
1
2
3
4
5
6
7
8
/*继承自Thread类*/
private static class UseExtendsThread extends Thread {
@Override
public void run() {
System.out.println("I am from the extends thread.");
}
}

  • 实现Runnable接口
1
2
3
4
5
6
7
8
9
10
/*实现Runnable接口*/
private static class UseRun implements Runnable{

@Override
public void run() {
System.out.println("I am implements Runnable");
}

}

1
2
3
4
5
6
7
8
9
10
/*实现Callable接口,允许有返回值*/
private static class UseCall implements Callable<String>{

@Override
public String call() throws Exception {
System.out.println("I am implements Callable");
return "I am the CallResult";
}

}
  • 实现Callable接口

Runnable和Callable的区别主要在于后者能够返回值。

下面是main函数中的启动方式,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args) 
throws InterruptedException, ExecutionException {
UseExtendsThread useExtendsThread = new UseExtendsThread();
useExtendsThread.start();

UseRun useRun = new UseRun();
new Thread(useRun).start();
Thread t = new Thread(useRun);
t.interrupt();

UseCall useCall = new UseCall();
FutureTask<String> futureTask = new FutureTask<>(useCall);
new Thread(futureTask).start();
System.out.println(futureTask.get());
}

注意Callable接口一般与FutureTask结合使用。

线程的停止

在Java里提供了stop(),suspend(),resume()方法,用来停止线程、挂起线程和恢复挂起的线程,但是这三个方法已不建议使用

  • 对于suspend()方法,在导致线程暂停的同时,并不释放任何资源,若其他线程也想访问它占用的锁时,也会受到影响导致无法运行。

  • 对于resume()方法,用于恢复被suspend挂起的程序,但是如果resume在suspend之前运行了,那就会导致挂起的线程继续挂起,它占用的锁也不会被释放,可能导致整个系统无法正常工作。

  • 对于stop()方法,会简单粗暴的停止线程,可能导致线程无法正确释放资源。

安全的停止线程-interrupt()、isInterrupted()、interrupted()

java线程是协作式,而非抢占式。

  • 当调用一个线程的interrupt() 方法会中断一个线程,但并不是强行关闭这个线程,只是跟这个线程打个招呼,将线程的中断标志位置为true,线程是否中断,由线程本身决定。
  • isInterrupted() 用于判定当前线程是否处于中断状态。
  • static方法interrupted() 判定当前线程是否处于中断状态,同时中断标志位改为false。

方法里如果抛出InterruptedException,线程的中断标志位会被复位成false,如果确实是需要中断线程,要求我们自己在catch语句块里再次调用interrupt()。

在下面的例子里,当主线程试图中断子线程时,sleep函数会抛出异常,清除掉中断标志位,为了使线程中断,我们需要重新调用interrupt()中断线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class HasInterrputException {

private static class UseThread extends Thread{

public UseThread(String name) {
super(name);
}

@Override
public void run() {
String threadName = Thread.currentThread().getName();
while(!isInterrupted()) {
try {
System.out.println(threadName + " is running");
Thread.sleep(300);
} catch (InterruptedException e) {
System.out.println(threadName+" catch interrput flag is "
+isInterrupted());
interrupt();
e.printStackTrace();
}
}
System.out.println(threadName+" interrput flag is "
+isInterrupted());
}
}

public static void main(String[] args) throws InterruptedException {
Thread endThread = new UseThread("exampleThread");
endThread.start();
System.out.println("Main Thread sleep 800 ms");
Thread.sleep(800);
System.out.println("Main begin interrupt thread !");
endThread.interrupt();
}
}

1.3 线程状态

线程可以有如下6种状态:

  • New ( 新创建 )
  • Runnable ( 可运行 )
  • Blocked ( 被阻塞 )
  • Waiting ( 等待)
  • Timed waiting ( 计时等待 )
  • Terminated ( 被终止 )

要确定当前线程的状态,可调用getState()方法。

新创建线程

当用new操作符创建一个新线程时,如newThread (r),该线程还没有开始运行。这意味 着它的状态是New,当一个线程处于新创建状态时 程序还没有开始运行线程中的代码 在 线程运行之前还有一些基础工作要做

可运行状态

一旦调用start方法,线程处于runnable状态。一个可运行的线桿可能正在运行也可能没 有运行,这取决于操作系统给线程提供运行的时间( Java 的规范说明没有将它作为一个单独状态。)

阻塞、等待、计时等待状态

  • 当一个线程试图获取一个内部的对象锁(而不是 java.util.concurrent 库中的锁 而该锁被其他线程持有,则该线程进人阻塞状态。当所有其他线程释放该锁 并且线程调度器允许,本线程持有它的时候 该线程将变成非阻塞状态。

  • 当线程等待另一个线程通知调度器一个条件时 它自己进入等待状态。在调用 Object.wait方法或Thread.join方法或者是等待java.util.concurrent库中的 Lock或Condition时,就会出现这种情况。注意,被阻塞状态与等待状态是有很大不同的。

  • 有几个方法有一个超时参数。调用它们导致线程进人计时等待( timed waiting ) 状态这一状态将一直保持到超时期满或者接收到适当的通知,带有超时参数的方法有Thread.sleep和Object.wait、Thread.join、Lock, try Lock 以及 Condition.await的计时版。

终止状态

线程因如下两个原因之一而被终止:

  • 因为run方法正常退出而自然死亡。
  • 因为一个没有捕获的异常终止了run方法而意外死亡。

可以调用线程的stop方法杀死一个线程,该方法抛出ThreadDeath错误对象,由此杀死线程。

线程状态之间的切换如下图:

Screen Shot 2019-11-28 at 9.41.23 PM.png

1.4 线程属性

线程优先级

Java中每个线程有一个优先级,默认情况下会继承父线程的优先级。可以用setPriority方法来设定线程的优先级,优先级在MIN_PRIORITY(1)和MAX_PRIORITY(10)之间。

注意:优先级的实现高度依赖系统,Java的优先级会被映射到宿主机平台的优先级上,因此有可能优先级变多或者变少,极端情况下,可能所有优先级映射到了宿主机的同一个优先级,因此不要过度依赖优先级。

优先级的设置不合理,可能导致低优先级的线程永远无法运行。

守护线程

可以通过调用t.setDaemon(true)来将线程转换为守护线程。守护线程的唯一功能就是为其他线程提供服务。当只剩下守护线程时,虚拟机会退出。

守护线程应该永远不去访问固有资源,如文件、数据库等,因为它可能在一个操作的中间发生中断。

1.5 线程同步与共享

在大多数实际的多线程应用中,两个或两个以上的线程需要共享对同一数据的存取。如果多个线程之间不进行协调与同步,无法保证在访问共享资源时的正确性。Java提供了一些用于线程共享的工具。

Synchronized内置锁

synchronized主要有两种用法,分别是

  • 对象锁,锁的是类的对象实例。
  • 类锁,锁的是每个类的的Class对象,每个类的的Class对象在一个虚拟机中只有一个,所以类锁也只有一个。

具体来讲,有如下几种用法,

1. 修饰代码块

被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象.

一个线程访问一个对象中的synchronized(this)同步代码块时,其他试图访问该对象的线程将被阻塞。类似如下操作,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 同步线程
*/
class SyncThread implements Runnable {
private static int count;
public SyncThread() {
count = 0;
}
public void run() {
synchronized(this) {
for (int i = 0; i < 5; i++) {
try {
System.out.println(Thread.currentThread().getName() + ":" + (count++));
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public int getCount() {
return count;
}
}

注意:当一个线程访问对象的一个synchronized(this)同步代码块时,另一个线程仍然可以访问该对象中的非synchronized(this)同步代码块。

2. 修饰一个方法

Synchronized修饰一个方法很简单,就是在方法的前面加synchronized.修饰方法和修饰一个代码块类似,只是作用范围不一样,修饰代码块是大括号括起来的范围,而修饰方法范围是整个函数。

1
2
3
4
5
6
7
8
9
10
public synchronized void run() {
for (int i = 0; i < 5; i ++) {
try {
System.out.println(Thread.currentThread().getName() + ":" + (count++));
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

虽然可以使用synchronized来定义方法,但synchronized并不属于方法定义的一部分,因此,synchronized关键字不能被继承。如果在父类中的某个方法使用了synchronized关键字,而在子类中覆盖了这个方法,在子类中的这个方法默认情况下并不是同步的,而必须显式地在子类的这个方法中加上synchronized关键字才可以。

3. 修饰一个静态的方法

Synchronized也可修饰一个静态方法,用法如下:

1
2
3
public synchronized static void method() {
// todo
}

4. 修饰一个类

Synchronized还可作用于一个类,用法如下:

1
2
3
4
5
6
7
class ClassName {
public void method() {
synchronized(ClassName.class) {
// todo
}
}
}

synchronized作用于一个类T时,是给这个类T加锁,T的所有对象用的是同一把锁。

总结

A. 无论synchronized关键字加在方法上还是对象上,如果它作用的对象是非静态的,则它取得的锁是对象;如果synchronized作用的对象是一个静态方法或一个类,则它取得的锁是对类,该类所有的对象同一把锁。
B. 每个对象只有一个锁(lock)与之相关联,谁拿到这个锁谁就可以运行它所控制的那段代码。
C. 实现同步是要很大的系统开销作为代价的,甚至可能造成死锁,所以尽量避免无谓的同步控制。

Volatile变量

Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。

当把变量声明成volatile类型后,编译器和运行时都会注意到这个变量是共享的,因此不会将改变量与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者其他处理器不可见的地方,因此在读取volatile类型的变量时,总会返回最新写入的值。

ThreadLocal

维护线程封闭性的一种更规范性的方法是ThreadLocal,这个类能使线程中的某个值与保存值的对象关联起来。ThreadLocal提供了get与set等访问接口与方法,这些方法为使用该变量的每个线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值。

当某个线程初次调用ThreadLocal.get方法时,就会调用initialValue来获取初始值。从概念上讲,你可以将ThreadLocal视为包含了Map<Thread, T>对象,其中保存了特定于该线程的值,但ThreadLocal的实现并非如此,这些特定的值保存在Thread对象中,当线程终止后,这些值会作为垃圾回收。

具体使用可以参考下面的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class UseThreadLocal {

//可以理解为 一个map,类型 Map<Thread,Integer>
static ThreadLocal<Integer> threadLaocl = new ThreadLocal<Integer>(){
@Override
protected Integer initialValue() {
return 1;
}
};

/**
* 运行3个线程
*/
public void StartThreadArray(){
Thread[] runs = new Thread[3];
for(int i=0;i<runs.length;i++){
runs[i]=new Thread(new TestThread(i));
}
for(int i=0;i<runs.length;i++){
runs[i].start();
}
}

/**
*类说明:测试线程,线程的工作是将ThreadLocal变量的值变化,并写回,看看线程之间是否会互相影响
*/
public static class TestThread implements Runnable{
int id;
public TestThread(int id){
this.id = id;
}
public void run() {
System.out.println(Thread.currentThread().getName()+":start");
Integer s = threadLaocl.get();//获得变量的值
s = s+id;
threadLaocl.set(s);
System.out.println(Thread.currentThread().getName()+":"
+threadLaocl.get());
//threadLaocl.remove();
}
}

public static void main(String[] args){
UseThreadLocal test = new UseThreadLocal();
test.StartThreadArray();
}
}

1.6 线程间的协作

等待与通知wait、notify、notifyAll

JDK提供了wait、notify、notifyAll方法来进行多个线程之间的协作,注意这些方法是在Object类中的。

  • 调用Object.wait()方法后,当前线程会在这个对象上等待;一直等待到其他线程调用了该对象对应的notifyAll对象为止。
  • 如果该对象上有多个线程调用了wait()方法,那样为了唤醒所有的线程,需要调用notifyAll()方法。

注意:一般wait和notifyAll配合使用,因为当有多个线程调用wait后,会进入到该对象的等待队列,如果调用notify,则只会从等待列表中随机唤醒一个线程。可能并不是我们想要的结果。

以下是wait和notify、notifyAll方法的工作流程:

Screen Shot 2019-11-29 at 12.45.25 PM.png

以下是notify和wait的使用范例,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class Express {
public final static String CITY = "ShangHai";
private int km;/*快递运输里程数*/
private String site;/*快递到达地点*/

public Express() {
}

public Express(int km, String site) {
this.km = km;
this.site = site;
}

/* 变化公里数,然后通知处于wait状态并需要处理公里数的线程进行业务处理*/
public synchronized void changeKm(){
this.km = 101;
notifyAll();
// notify();
}

/* 变化地点,然后通知处于wait状态并需要处理地点的线程进行业务处理*/
public synchronized void changeSite(){
this.site = "BeiJing";
notify();
}

public synchronized void waitKm(){
while(this.km<=100) {
try {
System.out.println("check km thread["+Thread.currentThread().getId()
+"] is still waiting.");
wait();
System.out.println("check km thread["+Thread.currentThread().getId()
+"] is notified.");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

public synchronized void waitSite(){
while(CITY.equals(this.site)) {
try {
System.out.println("check site thread["+Thread.currentThread().getId()
+"] is still waiting.");
wait();
System.out.println("check site thread["+Thread.currentThread().getId()
+"] is notified.");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

在测试程序中,启动了6个线程,当changeKm调用后,所有wait的线程被唤醒。但是由于城市未发生变化,因此检查城市的线程在被唤醒后继续等待。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class TestWN {
private static Express express = new Express(0,Express.CITY);

/*检查里程数变化的线程,不满足条件,线程一直等待*/
private static class CheckKm extends Thread{
@Override
public void run() {
express.waitKm();
}
}

/*检查地点变化的线程,不满足条件,线程一直等待*/
private static class CheckSite extends Thread{
@Override
public void run() {
express.waitSite();
}
}

public static void main(String[] args) throws InterruptedException {
for(int i=0;i<3;i++){//三个线程
new CheckSite().start();
}
for(int i=0;i<3;i++){//里程数的变化
new CheckKm().start();
}

Thread.sleep(1000);
express.changeKm();//快递地点变化
}
}

注意到调用wait和notify的方法都有synchronized关键字,因为在调用这些方法之前,都需要获得目标对象的监视器,执行完后会释放这个监视器。当某个线程被唤醒时,第一件事是试图获取目标对象的监视器,如果获取到了,则执行后续代码,否则一直等待获取监视器。

等待线程结束(join)和谦让(yeild)

join方法

当一个线程的输入可能非常依赖另一个线程或者多个线程的输出,此时,这个线程需要等待依赖线程执行完毕才能继续。JDK提供了join操作来实现这个功能,

比如在线程A里,执行了线程B.join()方法,线程A必须要等待B执行完成了以后,线程A才能继续自己的工作。

yeild方法

Thread.yield()是一个静态方法,一旦执行,他会使当前线程让出CPU,但是让出CPU并不表示当前线程不执行。当前线程在让出CPU之后,还会进行CPU资源的争夺,但是是否能被分配就不一定。

yield() 、sleep()、wait()、notify()等方法对锁有何影响

  • yield:让出时间片,不会释放锁

  • sleep:线程进入睡眠状态,不会释放锁

  • wait:必须拿到锁才能执行,执行后释放锁,进入锁的等待队列,方法被notify返回后重新拿到锁。

  • notify:必须拿到锁才能执行,执行后不会立马释放锁,而是通知等待队列中的某一个线程,同步代码块执行完毕后才会释放锁。本身是不会释放锁的。


本文由『后端精进之路』原创,首发于博客 http://teckee.github.io/ , 转载请注明出处

搜索『后端精进之路』关注公众号,立刻获取最新文章和价值2000元的BATJ精品面试课程

后端精进之路.png