Java多线程之深入理解并发编程基础

1 概念

多进程是指系统能同时运行多个任务(程序)。多线程是指在同一程序中有多个顺序流在执行。例如:在编辑或下载邮件的同时可以打印文件。
我的另一篇文章:Java基础之详解Thread与Runnable

1.1 进程与线程区别

进程和线程都是一个时间段的描述,是CPU工作时间段的描述,不过是颗粒大小不同。

  • 本质的区别在于每个进程拥有独立的内存单元;而线程则共享内存,共享内存使线程之间的通信比进程之间通信更有效、容易;
  • 进程是操作系统中应用程序的抽象概念;线程是应用程序调度的最小单位;
  • 一个程序至少有一个进程;一个进程至少有一个线程;
  • 线程在执行过程中与进程还是有区别的。每个独立的进程有一个程序运行的入口、顺序执行序列和程序的出口。但是线程不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制;
  • 从逻辑角度来看,多线程在于:一个应用程序中有多个执行部分可以同时执行。进程:实现进程的调度和管理以及资源分配。
1.2 什么是线程安全?

如果你的代码所在的进程中有多个线程在同时运行,而这些线程可能会同时运行这段代码。如果每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的。

或者说:一个类或者程序所提供的接口对于线程来说是原子操作,或者多个线程之间的切换不会导致该接口的执行结果存在二义性,也就是说我们不用考虑同步的问题,那就是线程安全的。

1.3 多线程同步问题

当多条语句在操作同一个线程共享数据时,一个线程对多条语句只执行了一部分,还没执行完,另一个线程参与进来执行,导致共享数据的错误。

3 多线程同步方法

3.1 synchronized关键字

public class SynchronizedThread {
    class Bank {
        private int account = 100;
        public int getAccount() {
           return account;
        }
        // 1.synchronized methods(){}
        public synchronized void save(int money) {
           account += money;
        }
        // 2.synchronized(this){}
        public void save(int money) {
           synchronized (this) {
               account += money;
           }
        }
   }
 
   class NewThread implements Runnable {
       private Bank bank;
       public NewThread(Bank bank) {
          this.bank = bank;
       }
       @Override
       public void run() {
          for (int i = 0; i < 10; i++) {
              // bank.save1(10);
              bank.save(10);
              System.out.println(i + "账户余额为:" + bank.getAccount());
          }
       }
   }
   // 建立线程,调用内部类
   public void useThread() {
	   Bank bank = new Bank();
       NewThread new_thread = new NewThread(bank);
       System.out.println("线程1");
       Thread thread1 = new Thread(new_thread);
       thread1.start();
       System.out.println("线程2");
       Thread thread2 = new Thread(new_thread);
       thread2.start();
   }

   public static void main(String[] args) {
	   SynchronizedThread st = new SynchronizedThread();
       st.useThread();
   }
} 

3.2 使用特殊域变量(volatile)实现线程同步

3.2.1 volatile关键字的作用

在当前的Java内存模型下,线程可以把变量保存在本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。

要解决这个问题,只需要把该变量声明为volatile,这就指示JVM,这个变量是不稳定的,每次使用它都到主存中进行读取。一般,多任务环境下各任务间共享的标志都应该加volatile修饰。

3.2.2 volatile关键字的两层语义

(1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
(2)禁止进行指令重排序。

3.2.3 volatile的原理和实现机制——volatile到底如何保证可见性和禁止指令重排序的?

下面这段话摘自《深入理解Java虚拟机》:“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”。lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:
(1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
(2)它会强制将对缓存的修改操作立即写入主存;
(3)如果是写操作,它会导致其他CPU中对应的缓存行无效。

3.2.4 volatile保证可见性

这里写图片描述
当线程访问某一个对象时候值的时候,首先通过对象的引用找到对应在主内存的变量的值,然后把堆内存变量的具体值读到线程本地内存中,建立一个变量副本,之后线程就不再和对象在堆内存变量值有任何关系,而是直接修改副本变量的值,在修改完之后的某一个时刻(线程退出之前)强制将线程变量副本的值立即写入主存。
Volatile修饰的成员变量在每次被线程访问时,都强迫从共享内存中重读该成员变量的值。而且当成员变量发生变化时,强迫线程将变化值回写到共享内存。这样在任何时刻,两个不同的线程总是看到某个成员变量的同一个值。
保证可见性:当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。

3.2.5 volatile保证原子性吗?

volatile不能完全确保线程安全:对于volatile修饰的变量,jvm虚拟机只是保证从主内存加载到线程工作内存的值是最新的。
volatile不能保证原子性,也就是在读写之后,如果主内存count变量发生修改之后,线程工作内存中的值由于已经加载,不会产生对应的变化,所以计算出来的结果会和预期不一样。例子如下:

public class ThreadTest2 {
	//产品
	static class ProductObject{
		//线程操作变量可见
		//volatile关键字为域变量的访问提供了一种免锁机制,使用volatile修饰域相当于告诉虚拟机该域可能会被其他线程更新
		public volatile static String value;
	}
	// 生产者线程
	static class Producer extends Thread {
		Object lock;
		public Producer(Object lock) {
			this.lock = lock;
		}
		@Override
		public void run() {
			// 不断生产产品
			for (int i = 1; i < 10; i++) {
				// 产品已经消费完成,生产新的产品
				ProductObject.value = "NO:" + System.currentTimeMillis();
				System.out.println("生产产品:" + ProductObject.value);
			}
		}
	}
	// 消费者线程
	static class Consumer extends Thread {
		Object lock;
		public Consumer(Object lock) {
			this.lock = lock;
		}
		@Override
		public void run() {
			for (int i = 1; i < 10; i++) {
				// 产品已经消费完成,生产新的产品
				System.out.println("消费产品:" + ProductObject.value);
				ProductObject.value = null;
			}
		}
	}

	public static void main(String[] args) {
		Object lock = new Object();
		new Producer(lock).start();
		new Consumer(lock).start();
	}
}

这里写图片描述
用final域,有锁保护的域和volatile域可以避免非同步的问题,详解如 4 线程间的通信:ThreadTest3。

3.2.6 volatile能保证有序性吗?

在前面提到volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。

//x、y为非volatile变量  
//flag为volatile变量  
   
x = 2;        //语句1  
y = 0;        //语句2  
volatile flag = true;  //语句3  
x = 4;        //语句4  
y = -1;       //语句5

由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。

3.2.7 synchronized和volatile变量对比

(1)volatile变量是一种稍弱的同步机制在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量级的同步机制。
(2)从内存可见性的角度看,写入volatile变量相当于退出同步代码块,而读取volatile变量相当于进入同步代码块。
(3)在代码中如果过度依赖volatile变量来控制状态的可见性,通常会比使用锁的代码更脆弱,也更难以理解。仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它。
(4)synchronized机制(即同步机制)既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。原因是声明为volatile的简单变量如果当前值与该变量以前的值相关,那么volatile关键字不起作用,也就是说如下的表达式都不是原子操作:“count++”、“count = count+1”。

3.2.8 使用volatile关键字的场景

通常来说,使用volatile必须具备以下2个条件:
(1)对变量的写操作不依赖于当前值,或者你能确保只有单个线程更新变量的值。比如:i++、i+=1这种。但是可以改为num=i+1,如果i是一个 volatile 类型,那么num就是安全的,总之就是不能作用于自身。
(2)该变量没有包含在具有其他变量的不变式中。
这些条件表明:可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。

以下为2个场景例子:

//(1)状态标记量
volatile boolean inited = false;  
Thead1 {
	context = loadContext();    
	inited = true;    
}          
Thead2 {
	while(!inited ) {  
		sleep()  
	}  
	doSomething(context); 
}
//(2)DCL(Double CheckLock)实现单例(常用)
3.2.9 总结

需要同步的时候,第一选择应该是synchronized关键字,这是最安全的方式,尝试其他任何方式都是有风险的。尤其在、jdK1.5之后,对synchronized同步机制做了很多优化,如:自适应的自旋锁、锁粗化、锁消除、轻量级锁等,使得它的性能明显有了很大的提升。

3.3 使用重入锁实现线程同步

在JavaSE5.0中新增了一个java.util.concurrent包来支持同步。ReentrantLock类是可重入、互斥、实现了Lock接口的锁, 它与使用synchronized方法和快具有相同的基本行为和语义,并且扩展了其能力。ReenreantLock类的常用方法有:

ReentrantLock() : 创建一个ReentrantLock实例 
lock() : 获得锁 
unlock() : 释放锁

注:ReentrantLock()还有一个可以创建公平锁的构造方法,但由于能大幅度降低程序运行效率,不推荐使用。

 class Bank {
      private int account = 100;
      //需要声明这个锁
      private Lock myLock = new ReentrantLock();
      public int getAccount() {
            return account;
      }
      public void save(int money) {
            myLock.lock(); //锁住
            try{
                account += money;
            } finally {
                myLock.unlock(); //如果在临界区的代码抛出异常,锁必须被释放。否则,其他线程将永远阻塞。
            } 
      }
} 

4 线程间的通信

4.1 概念

等待唤醒机制----wait及notify方法。注意:Adater的notifyDatasetChanged()方法通知ListView更新显示,基于此原理。

  • wait()方法使得当前线程必须要等待,等到另外一个线程调用notify()或者notifyAll()方法。
  • notify()方法会唤醒一个等待当前对象的锁的线程。

4.2 线程阻塞

阻塞状态的线程的特点是:该线程放弃CPU的使用,暂停运行,只有等到导致阻塞的原因消除之后才恢复运行。或者是被其他的线程中断,该线程也会退出阻塞状态,同时抛出InterruptedException。以下为线程阻塞条件:

  • 线程执行了Thread.sleep(int millsecond);方法,当前线程放弃CPU,睡眠一段时间,然后再恢复执行
  • 线程执行一段同步代码,但是尚且无法获得相关的同步锁,只能进入阻塞状态,等到获取了同步锁,才能回复执行。
  • 线程执行了一个对象的wait()方法,直接进入阻塞状态,等待其他线程执行notify()或者notifyAll()方法。
  • 线程执行某些IO操作,因为等待相关的资源而进入了阻塞状态。比如说监听system.in,但是尚且没有收到键盘的输入,则进入阻塞状态。

4.3 生产者消费者例子

public class ThreadTest3 {
	// 产品
	static class ProductObject {
		// 线程操作变量可见
		// volatile关键字为域变量的访问提供了一种免锁机制,使用volatile修饰域相当于告诉虚拟机该域可能会被其他线程更新
		public volatile static String value;
	}
	// 生产者线程
	static class Producer extends Thread {
		Object lock;
		public Producer(Object lock) {
			this.lock = lock;
		}
		@Override
		public void run() {
			// 不断生产产品
			for (int i = 1; i < 10; i++) {
				synchronized (lock) { // 互斥锁
					// 产品还没有被消费,等待
					if (ProductObject.value != null) {
						try {
							lock.wait();// 等待,阻塞
						} catch (InterruptedException e) {
							e.printStackTrace();
						}
					}
					// 产品已经生产完成
					ProductObject.value = "NO:" + System.currentTimeMillis();
					System.out.println("生产产品:" + ProductObject.value);
					lock.notify(); // 生产完成,通知消费者消费(唤醒一个等待当前对象的锁的线程)
				}
			}
		}
	}
	// 消费者线程
	static class Consumer extends Thread {
		Object lock;
		public Consumer(Object lock) {
			this.lock = lock;
		}
		@Override
		public void run() {
			for (int i = 1; i < 10; i++) {
				synchronized (lock) {
					// 没有产品可以消费
					if (ProductObject.value == null) {
						try {
							lock.wait();// 等待,阻塞
						} catch (InterruptedException e) {
							e.printStackTrace();
						}
					}
					// 产品已经消费完成
					System.out.println("消费产品:" + ProductObject.value);
					ProductObject.value = null;
					lock.notify(); // 消费完成,通知生产者,继续生产(唤醒一个等待当前对象的锁的线程)
				}
			}
		}
	}

	public static void main(String[] args) {
		Object lock = new Object();
		new Producer(lock).start();
		new Consumer(lock).start();
	}
}

这里写图片描述

5 线程死锁

5.1 死锁的四个必要条件

  • 互斥条件,即某个资源在一段时间内只能由一个线程占有,不能同时被两个或两个以上的线程占有
  • 不可抢占条件,线程所获得的资源在未使用完毕之前,资源申请者不能强行地从资源占有者手中夺取资源,而只能由该资源的占有者线程自行释放
  • 占有且申请条件,线程至少已经占有一个资源,但又申请新的资源;由于该资源已被另外线程占有,此时该线程阻塞;但是,它在等待新资源之时,仍继续占用已占有的资源。
  • 循环等待条件,存在一个线程等待序列{P1,P2,…,Pn},其中P1等待P2所占有的某一资源,P2等待P3所占有的某一源,…,而Pn等待P1所占有的的某一资源,形成一个线程循环等待环。

5.2 死锁的四个必要条件

public class MyTask implements Runnable {
//    //字符串常量是存放在常量池,new两个Task,两个字符串常量"obj1"是同一个对象。
//    //两个Task引用的是同一个"obj1"对象,起不到两份数据的效果。
//    private final Object obj1 = "obj1"; //资源1
//    private final Object obj2 = "obj2"; //资源2
    private final Object obj1 = new String("obj1"); //资源1
    private final Object obj2 = new String("obj2"); //资源2

    private int flag;
    public void setFlag(int flag) {
        this.flag = flag;
    }

    @Override
    public void run() {
        if (flag == 1) {
            synchronized (obj1) {
                Log.e("Task", "锁住:" + obj1); //占用obj1
                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (obj2) {
                    Log.e("Task", "使用顺序obj1->obj2");
                }
            }
        } else if (flag == 2) {
            synchronized (obj2) {
                Log.e("Task", "锁住:" + obj2); //占用obj2
                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (obj1) {
                    Log.e("Task", "使用顺序obj2->obj1");
                }
            }
        }
    }
}
public class DeadLockTest {
	/**
	 * 死锁
	 */
	public static void deadLock() {
		MyTask task = new MyTask();
		task.setFlag(1);
		Thread t1 = new Thread(task);
		t1.start();

		try {
			Thread.sleep(100);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}

		//改变条件
		task.setFlag(2);
		Thread t2 = new Thread(task);
		t2.start();
	}

	/**
	 * 解决死锁的实现方法
	 */
	public static void removeDeadLock() {
		MyTask task1 = new MyTask();
		task1.setFlag(1);
		Thread t1 = new Thread(task1);
		t1.start();

		try {
			Thread.sleep(100);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}

        //新创建一个MyTask
		MyTask task2 = new MyTask();
		task2.setFlag(2);
		Thread t2 = new Thread(task2);
		t2.start();
	}
}
//死锁结果
//Task: 锁住:obj1
//Task: 锁住:obj2

//解决死锁后结果
//Task: 锁住:obj1
//Task: 锁住:obj2
//Task: 使用顺序obj1->obj2
//Task: 使用顺序obj2->obj1

6 如何优雅的结束线程

线程对象属于一次性消耗品,一般线程执行完run方法之后,线程就正常结束了,线程结束之后就报废了,不能再次start,只能新建一个线程对象。但有时run方法是永远不会结束的。有三种方法可以结束线程:

  • 使用退出标志,使线程正常退出,也就是当run方法完成后线程终止;
  • 使用interrupt()方法中断线程;
  • 使用stop方法强行终止线程(不推荐使用,可能发生不可预料的结果)

6.1 使用退出标志终止线程

使用一个变量来控制循环,例如最直接的方法就是设一个boolean类型的标志,并通过设置这个标志为true或false来控制while循环是否退出。代码如下:

public class ThreadSafe extends Thread {  
    public volatile boolean exit = false;   
        public void run() {   
        while (!exit){  
            //do something  
        }  
    }   
}   

定义了一个退出标志exit,当exit为true时,while循环退出,exit的默认值为false.在定义exit时,使用了一个Java关键字volatile,这个关键字的目的是使exit同步,也就是说在同一时刻只能由一个线程来修改exit的值。

6.2 使用interrupt()方法终止线程

使用interrupt()方法来终端线程可分为两种情况:
(1)线程处于阻塞状态,如使用了sleep,同步锁的wait,socket的receiver,accept等方法时,会使线程处于阻塞状态。当调用线程的interrupt()方法时,系统会抛出一个InterruptedException异常,代码中通过捕获异常,然后break跳出循环状态,使线程正常结束。通常很多人认为只要调用interrupt方法线程就会结束,实际上是错的,一定要先捕获InterruptedException异常之后通过break来跳出循环,才能正常结束run方法。

public class ThreadSafe extends Thread {  
    public void run() {   
        while (true){  
            try{  
                    Thread.sleep(5*1000);阻塞5妙  
                }catch(InterruptedException e){  
                    e.printStackTrace();  
                    break;//捕获到异常之后,执行break跳出循环。  
                }  
        }  
    }   
}  

(2)线程未进入阻塞状态,使用isInterrupted()判断线程的中断标志来退出循环,当使用interrupt()方法时,中断标志就会置true,和使用自定义的标志来控制循环是一样的道理。

public class ThreadSafe extends Thread {  
    public void run() {   
        while (!isInterrupted()){  
            //do something, but no tthrow InterruptedException  
        }  
    }   
}  

(3)为什么要区分进入阻塞状态和和非阻塞状态两种情况了,是因为当阻塞状态时,如果有interrupt()发生,系统除了会抛出InterruptedException异常外,还会调用interrupted()函数,调用时能获取到中断状态是true的状态,调用完之后会复位中断状态为false,所以异常抛出之后通过isInterrupted()是获取不到中断状态是true的状态,从而不能退出循环,因此在线程未进入阻塞的代码段时是可以通过isInterrupted()来判断中断是否发生来控制循环,在进入阻塞状态后要通过捕获异常来退出循环。因此使用interrupt()来退出线程的最好的方式应该是两种情况都要考虑:

public class ThreadSafe extends Thread {  
    public void run() {   
        while (!isInterrupted()){ //非阻塞过程中通过判断中断标志来退出  
            try{  
                Thread.sleep(5*1000);//阻塞过程捕获中断异常来退出  
            }catch(InterruptedException e){  
                e.printStackTrace();  
                break;//捕获到异常之后,执行break跳出循环。  
            }  
        }  
    }   
}   

6.3 使用stop方法终止线程

程序中可以直接使用thread.stop()来强行终止线程,但是stop方法是很危险的,就象突然关闭计算机电源,而不是按正常程序关机一样,可能会产生不可预料的结果,不安全主要是:thread.stop()调用之后,创建子线程的线程就会抛出ThreadDeatherror的错误,并且会释放子线程所持有的所有锁。一般任何进行加锁的代码块,都是为了保护数据的一致性,如果在调用thread.stop()后导致了该线程所持有的所有锁的突然释放(不可控制),那么被保护数据就有可能呈现不一致性,其他线程在使用这些被破坏的数据时,有可能导致一些很奇怪的应用程序错误。因此,并不推荐使用stop方法来终止线程。

7 并发处理方法

7.1 局部变量是否存在并发问题

(1)不存在。因为每个线程都有自己的调用栈,局部变量保存在线程各自的调用栈里面,不会共享,所以自然也就没有并发问题。
(2)没有共享,就没有伤害。方法里的局部变量,因为不会和其他线程共享,所以没有并发问题,这个思路很好,已经成为解决并发问题的一个重要技术,同时还有个名字叫:线程封闭,官方的解释是:仅在单线程内访问数据。由于不存在共享,所以即便不同步也不会有并发问题,性能杠杠的。

8 参考链接

深入理解volatile关键字

Java 并发:volatile 关键字解析

Java JVM(十二):指令重排序

Java多线程看这一篇就足够了

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值