java并发编程

 

1.多线程知识复习

1.1 线程和进程的区别

      每个正在系统上运行的程序都是一个进程。进程是线程的集合。线程是一组指令的集合,是进程的执行路径。

1.2 多线程的应用场景

      迅雷多线程下载、QQ、爬虫、分布式job(需要同时执行多个任务调度)。另外tomcat内部采用的也是多线程,上百个客户端访问同一个web应用,tomcat接入后都是把后续的处理扔给一个新的线程来处理,这个新的线程最后调用到我们的servlet程序。

1.3 线程的创建方式

      1)继承Thread类
      2)实现Runnable接口

      3)使用Callable和Future创建线程

 

2.多线程同步

2.1 线程安全问题

       线程安全:当多个线程访问某一个类(对象或方法)时,这个类始终都能表现出正确的行为,那么这个类(对象或方法)就是线程安全的。
       当多个线程共享同一个全局变量或静态变量,同时做写的操作时,可能会发生数据冲突问题,也就是线程安全问题。


       什么情况下会产生线程安全问题?

           1)存在多个线程共享同一资源;  2)多线程操作共享资源的代码有多句。

2.2 线程安全问题的解决方式

      1)同步代码块
      2)同步函数
非静态同步函数的锁对象是this对象。

public synchronized void m() { //等同于在方法的代码执行时要synchronized(this)
        count--;
        System.out.println(Thread.currentThread().getName() + " count = " + count);
    }


静态同步函数的锁对象是当前函数所属的类的字节码文件。

public class T {
 
    private static int count = 10;
     
    public synchronized static void m() { //这里等同于synchronized(T.class)
        count--;
        System.out.println(Thread.currentThread().getName() + " count = " + count);
    }
 
}


2.3.Java内存模型

      Java内存模型简称JMM(Java Memory Model),是Java虚拟机所定义的一种抽象规范,用来屏蔽不同硬件和操作系统的内存访问差异,让java程序在各种平台下都能达到一致的内存访问效果。

      

 

      1).主内存(Main Memory)
      主内存可以简单理解为计算机当中的内存,但又不完全等同。主内存被所有的线程所共享,对于一个共享变量(比如静态变量,或是堆内存中的实例)来说,主内存当中存储了它的“本尊”。
      2).工作内存/本地内存(Working Memory)
      工作内存可以简单理解为计算机当中的CPU高速缓存,但又不完全等同。每一个线程拥有自己的工作内存,对于一个共享变量来说,工作内存当中存储了它的“副本”。
       线程对共享变量的所有操作都必须在工作内存进行,不能直接读写主内存中的变量。不同线程之间也无法访问彼此的工作内存,变量值的传递只能通过主内存来进行。
      总结:共享变量存放在主内存中,每个线程都有自己的本地内存,当多个线程同时访问一个数据的时候,可能本地内存没有及时刷新到主内存,所以就会发生线程安全问题。


2.4 多线程编程应考虑的三个特性

2.4.1 原子性

      即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
      原子性其实就是保证数据一致、线程安全的一部分。

2.4.2 可见性

       可见性指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。可见性就是在多核或者多线程运行过程中内存的一种共享模式,在JMM模型里面,通过并发线程修改变量值的时候,必须将线程变量同步回主存过后,其他线程才可能访问到。
       若两个线程在不同的cpu,那么线程1改变了i的值还没刷新到主存,线程2又使用了i,那么这个i值肯定还是之前的,线程1对变量的修改线程没看到这就是可见性问题。

2.4.3 有序性

      有序性即程序执行的顺序按照代码的先后顺序执行。
      一般来说处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。  如下:
int a = 10;    //语句1
int r = 2;    //语句2
a = a + 3;    //语句3
r = a*a;     //语句4
则因为重排序,他还可能执行顺序为 2-1-3-4,1-3-2-4
但绝不可能 2-1-4-3,因为这打破了依赖关系。
显然重排序对单线程运行是不会有任何问题,而多线程就不一定了,所以我们在多线程编程时就得考虑这个问题了。

2.5.volatile关键字

      使用volatile关键字可以保证变量在线程之间的可见性。另外volatile阻止编译时和运行时的指令重排。
      但是volatile关键字并不能保证原子性。请看示例。


      volatile关键字和synchronized关键字的区别:
      ①volatile轻量级,只能修饰变量。synchronized重量级,还可修饰方法。
      ②volatile只能保证数据的可见性,不能用来同步,因为多个线程并发访问volatile修饰的变量不会阻塞。synchronized不仅保证可见性,而且还保证原子性,因为,只有获得了锁的线程才能进入临界区,从而保证临界区中的所有语句都全部执行。多个线程争抢synchronized锁对象时,会出现阻塞。

2.6.AtomicXXX类

AtomicInteger是一个提供原子操作的Integer类,通过线程安全的方式操作加减,比synchronized效率更高。类似的还有AtomicBoolean和AtomicLong。

2.7.脏读问题

对业务写方法加锁,对业务读方法不加锁,容易产生脏读问题(dirtyRead)。请看示例。

3.线程间的通信

3.1 wait()、notify()、notifyAll()方法复习

      wait()、notify()、notifyAll()是三个定义在Object类里的方法,可以用来控制线程的状态。以上三个方法仅能由锁对象调用。
      如果对象调用了wait方法就会使持有该对象的线程把该锁对象的控制权交出去,然后处于等待状态;如果对象调用了notify方法就会通知某个正在等待这个锁对象的控制权的线程可以继续运行;如果对象调用了notifyAll方法就会通知所有等待这个锁对象控制权的线程继续运行。
      注意:以上三个方法只能在同步代码块/同步方法中使用,并且由同一个锁对象调用。

3.2 ReentrantLock

      并发包中新增了 Lock 接口(以及相关实现类)用来实现锁功能,Lock 接口提供了与 synchronized 关键字类似的同步功能,但需要在使用时手动获取锁和释放锁。用法:

Lock lock  = new ReentrantLock();
lock.lock();
try{
//可能会出现线程安全问题的操作
}finally{
//一定在finally中释放锁
  lock.unlock();
}
  • 使用Reentrantlock可以进行“尝试锁定”tryLock,这样当无法锁定或者在指定时间内无法锁定时,线程可以决定是否继续等待。详见示例。
  • 使用ReentrantLock还可以调用lockInterruptibly方法,可以对线程interrupt方法做出响应,在一个线程等待锁的过程中,可以被打断。
  • ReentrantLock可以指定为公平锁,公平锁表示线程获取锁的顺序是按照线程排队的顺序来分配的。    

3.3 Condition用法

      Condition对象通过调用lock对象的newCondition()方法实例化,其await()和signal()方法类似于Object.wait()和Object.notify()。

Condition condition = lock.newCondition();
condition.await();  //类似wait()
condition.signal(); //类似notify()
Condition.signalAll(); //类似notifyAll()

      Condition比传统对象的wait()/notify的优势:假设现在有多个线程都处于等待某锁的状态,使用notify()无法指定唤醒哪一个线程,而condition.signal()可以,通过new多个Condition实现。详见示例。

3.4 Lock接口和synchronized关键字的区别

存在层次 java关键字 一个接口
锁的释放

①获取到锁的线程执行完同步代码后,才释放锁

②当线程执行发生异常,会释放锁

在finally中必须释放锁,不然容易造成线程死锁。
锁的获取 假设A线程已取得锁,则其它线程会等待。如果A线程阻塞,则其它线程会一直等待 lock()/tryLock()/lockInterruptibly()
锁状态 无法判断 可以判断
锁类型 可重入,不可中断,非公平 可重入,可中断,可指定公平性

4.其它常用并发类

4.1 CountDownLatch

      CountDownLatch类位于java.util.concurrent包下,利用它可以实现类似计数器的功能。比如有一个任务A,它要等待其他3个任务执行完毕之后才能执行,此时就可以利用CountDownLatch来实现这种功能了。经常用于监听某些初始化操作,当初始化操作执行完后通知主线程继续工作。详见示例。

4.2 CyclicBarrier

      CyclicBarrier初始化时规定一个数目,然后计算调用了CyclicBarrier.await()进入等待的线程数。当线程数达到了这个数目时,所有进入等待状态的线程被唤醒并继续。CyclicBarrier就象它名字的意思一样,可看成是个障碍,所有的线程必须到齐后才能一起通过这个障碍。 
      示例:跑步比赛,所有参赛选手到齐后才能开赛。
      CyclicBarrier初始时还可带一个Runnable的参数,此Runnable任务在CyclicBarrier的数目达到后,所有其它线程被唤醒前被执行。

4.3 Semaphore

      Semaphore是一种基于计数的信号量。它可以设定一个阈值,基于此,多个线程竞争获取许可信号,获取并使用完后归还,超过阈值后,线程申请许可信号将会被阻塞。Semaphore可以用来构建一些对象池,资源池之类的,比如数据库连接池,我们也可以创建计数为1的Semaphore,将其作为一种类似互斥锁的机制,这也叫二元信号量,表示两种互斥状态。它的用法如下:
      availablePermits():获取当前可用的资源数量
      acquire():用来获取一个许可,若无许可能够获得,则会一直等待,直到获得许可。
      release():用来释放许可。注意,在释放许可之前,必须先获获得许可。
      示例:假若一个工厂有5台机器,但是有8个工人,一台机器同时只能被一个工人使用,只有使用完了,其他工人才能继续使用。

4.4 小结

1)CountDownLatch和CyclicBarrier都能够实现线程之间的等待,只不过它们侧重点不同:

      ①CountDownLatch一般用于某个线程A等待若干个其他线程执行完任务之后,它才执行,而CyclicBarrier一般用于一组线程互相等待至某个状态,然后这一组线程再同时执行;

      ②另外,CountDownLatch是不能够重用的,而CyclicBarrier是可以重用的。
2)Semaphore其实和锁有点类似,它一般用于控制对某组资源的访问权限。

5.并发队列

5.1 引入:再看卖票问题

      使用集合将票保存起来,ArrayList线程不安全,未进行同步,程序会出现重复销售和超量销售的问题。
      那么使用线程安全的Vector集合能解决问题吗?
      Vector的size()和remove(int i)方法均加了synchronized关键字,但是程序依旧会出现超量销售的问题。因为判断size和进行remove必须是一整个的原子操作。就算操作A和B都是同步的,但A和B组成的复合操作也未必是同步的,仍然需要自己进行同步。
      使用并发队列ConcurrentLinkedQueue可以更高效地解决该问题。

5.2 非阻塞队列ConcurrentLinkedQueue

      ConcurrentLinkedQueue是一个适用于高并发场景下的队列,通过CAS无锁的方式,实现了高并发状态下的高性能,它是一个基于链接节点的线程安全的无界非阻塞队列。通常ConcurrentLinkedQueue性能好于BlockingQueue。该队列的元素遵循先进先出的原则。该队列不允许null元素。
ConcurrentLinkedQueue主要方法:
add()和offer()都是加入元素的方法,在ConcurrentLinkedQueue中这俩个方法没有任何区别。
poll()和peek()都是取头元素节点,区别在于前者会删除元素,后者不会。若队列中没有元素,则返回值为空。

5.3 阻塞队列BlockingQueue

      阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作是:在队列为空时,获取元素的线程会等待队列变为非空。当队列满时,存储元素的线程会等待队列可用。
阻塞队列常用于生产者和消费者的场景,生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程。阻塞队列就是生产者存放元素的容器,而消费者也只从容器里拿元素。阻塞队列提供了四种处理方法:

 

  • 抛出异常:是指当阻塞队列满时候,再往队列里插入元素,会抛出IllegalStateException(“Queue full”)异常。当队列为空时,从队列里获取元素时会抛出NoSuchElementException异常 。
  • 返回特殊值:插入方法会返回是否成功,成功则返回true。移除方法,则是从队列里拿出一个元素,如果没有则返回null
  • 一直阻塞:当阻塞队列满时,如果生产者线程往队列里put元素,队列会一直阻塞生产者线程,直到拿到数据,或者响应中断退出。当队列空时,消费者线程试图从队列里take元素,队列也会阻塞消费者线程,直到队列可用。
  • 超时退出:当阻塞队列满时,队列会阻塞生产者线程一段时间,如果超过一定的时间,生产者线程就会退出。


常用的阻塞队列包括LinkedBlockingQueue、ArrayBlockingQueue、LinkedTransferQueue、 SynchronusQueue、DelayQueue。下面分别进行介绍。

5.3.1 LinkedBlockingQueue

      LinkedBlockingQueue是一个用链表实现的有界阻塞队列,我们可以在初始化时指定它的容量大小,如果不指定,就采用默认大小为Integer.MAX_VALUE的容量 。遵循元素先进先出。

5.3.2 ArrayBlockingQueue

      ArrayBlockingQueue是一个用数组实现的有界阻塞队列。元素先进先出。默认情况下不保证访问者公平的访问队列,所谓公平访问队列是指阻塞的所有生产者线程或消费者线程,当队列可用时,可以按照阻塞的先后顺序访问队列,即先阻塞的生产者线程,可以先往队列里插入元素,先阻塞的消费者线程,可以先从队列里获取元素。通常情况下为了保证公平性会降低吞吐量。可以使用如下代码创建公平队列:
ArrayBlockingQueue fairQueue = new  ArrayBlockingQueue(1000,true);

5.3.3 DelayQueue

      DelayQueue是一个支持延时获取元素的无界阻塞队列。队列使用PriorityQueue来实现。队列中的元素必须实现Delayed接口,在创建元素时可以指定多久才能从队列中获取当前元素。只有在延迟期满时才能从队列中提取元素。
我们可以将DelayQueue运用在以下应用场景:

  • 缓存系统的设计:可以用DelayQueue保存缓存元素的有效期,使用一个线程循环查询DelayQueue,一旦能从DelayQueue中获取元素时,表示缓存有效期到了。
  • 定时任务调度。使用DelayQueue保存当天将会执行的任务和执行时间,一旦从DelayQueue中获取到任务就开始执行。

      队列中的Delayed必须实现compareTo来指定元素的顺序。比如让延时时间最长的放在队列的末尾。

5.3.4 LinkedTransferQueue

      LinkedTransferQueue是一个由链表结构组成的无界阻塞TransferQueue队列。相对于其他阻塞队列LinkedTransferQueue多了tryTransfer和transfer方法。
      transfer方法:如果当前有消费者正在等待接收元素(消费者使用take()方法或带时间限制的poll()方法时),transfer方法可以把生产者传入的元素立刻transfer(传输)给消费者。如果没有消费者在等待接收元素,transfer方法会将元素存放在队列的tail节点,并等到该元素被消费者消费了才返回。
      tryTransfer方法:则是用来试探下生产者传入的元素是否能直接传给消费者。如果没有消费者等待接收元素,则返回false。和transfer方法的区别是tryTransfer方法无论消费者是否接收,方法立即返回。而transfer方法是必须等到消费者消费了才返回。

5.3.5 SynchronousQueue

      SynchronousQueue是一个不存储元素的阻塞队列。每一个put()操作必须等待一个take()操作,否则不能继续添加元素。没有消费者时,调用add()方法会报错。SynchronousQueue可以看成是一个传球手,负责把生产者线程处理的数据直接传递给消费者线程。队列本身并不存储任何元素,非常适合于传递性场景,比如在一个线程中使用的数据,传递给另外一个线程使用,SynchronousQueue的吞吐量高于LinkedBlockingQueue 和 ArrayBlockingQueue。

6.线程池

6.1 认识线程池

      在之前发布的《阿里巴巴 Java 手册》里有一条:
           3. 【强制】线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。
          说明:使用线程池的好处是减少在创建和销毁线程上所花的时间以及系统资源的开销,解决资源不足的问题。如果不使用线程池,有可能造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题。
由此可见线程池的重要性。简单来说使用线程池有以下几个目的

  • 线程是稀缺资源,不能频繁的创建。
  • 解耦作用;线程的创建与执行完全分开,方便维护。
  • 通过重复利用已创建的线程降低线程创建和销毁造成的消耗。当任务到达时,任务可以不需要等到线程创建就能立即执行。

6.2 认识常用接口和类

 

 

      Executor:一个接口,其定义了一个接收Runnable对象的方法execute,其方法签名为execute(Runnable command),
      ExecutorService:是一个比Executor使用更广泛的子类接口,其提供了生命周期管理的方法,以及可跟踪一个或多个异步任务执行状况返回Future的方法
      AbstractExecutorService:ExecutorService执行方法的默认实现
     ThreadPoolExecutor:线程池,可以通过以下构造方法来创建线程池:

 

        参数说明:
        corePoolSize:核心线程数,如果运行的线程少于corePoolSize,则创建新线程来执行新任务,即使线程池中的其他线程是空闲的
        maximumPoolSize:最大线程数,可允许创建的线程数,corePoolSize和maximumPoolSize设置的边界自动调整池大小:
        corePoolSize <运行的线程数< maximumPoolSize:仅当队列满时才创建新线程
        corePoolSize=运行的线程数= maximumPoolSize:创建固定大小的线程池
        keepAliveTime:如果线程数多于corePoolSize,则这些多余的线程的空闲时间超过keepAliveTime时将被终止
       unit:keepAliveTime参数的时间单位
       workQueue:保存任务的阻塞队列,与线程池的大小有关:
             当运行的线程数少于corePoolSize时,在有新任务时直接创建新线程来执行任务而无需再进队列
             当运行的线程数等于或多于corePoolSize,在有新任务添加时则选加入队列,不直接创建线程
            当队列满时,在有新任务时就创建新线程
       通过该方法可以自定义线程池。

6.3 几种常用线程池

6.3.1 FixedThreadPool

      创建一个定长线程池,可控制线程最大并发数,超出数量的新线程会在队列中等待。

6.3.2 CachedThreadPool

      创建一个可缓存线程池,如果线程池大小超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。每个线程有一定生命周期,默认60s,60s后会回收线程。当有新任务时,看池里有没有空闲的线程,有的话直接使用,没有的话才创建新线程。

6.3.3 SingleThreadExecutor

      创建一个始终只有一个线程的线程池,它只会用唯一的工作线程来执行任务。

6.3.4 ScheduledThreadPool

      创建一个定长线程池,支持定时及周期性任务执行。调用ScheduledThreadPool的scheduleAtFixedRate(Runnable command,long initialDelay,long period, TimeUnit unit)方法实现定时任务的执行。

7.总结

      使用并发队列和线程池实现生产者消费者模式。

 

posted on 2018-10-08 16:30  harderyao  阅读(93)  评论(0)    收藏  举报

导航