Java重新出发--Java学习笔记(九)--并发基础

我们在使用电脑的时候,可以同时运行多个程序,可以一边编代码一边听音乐。
这是因为我们的操作系统允许我们并发执行任务,那么我们在程序中要如何实现这个功能呢?

public class Tester {
    public static void main(String[] args) {
        // TODO Auto-generated method stub
        System.out.println("开始。。。");
        playGame();
        playMusic();
        System.out.println("结束。。。");
    }
    public static void playGame(){
        for(int i = 0;i < 50;i++){
            System.out.println("打游戏"+i);
        }
    }
    
    public static void playMusic(){
        for(int i = 0;i < 50;i++){
            System.out.println("播放音乐"+i);
        }
    }
}

显而易见,只有执行完打游戏后才会去进行播放音乐的功能。

并行与并发

这两个概念是相似但是又有区别的。
并行性是指两个以上的事务在同一时刻发生,而并发性是指两个以上事务在同一时间间隔内发生.

在多道程序环境下,并发是指在一段时间内宏观上有多个程序在同时进行,但在单处理机环境下(一个处理器),每一时刻却仅能有一道程序执行,微观上其实是交替执行。

例如,在1秒钟时间内,0-15ms程序A运行;15-30ms程序B运行;30-45ms程序C运行;45-60ms程序D运行,因此可以说,在1秒钟时间间隔内,宏观上有四道程序在同时运行,但微观上,程序A、B、C、D是分时地交替执行的。

如果在计算机系统中有多个处理机,这些可以并发执行的程序就可以被分配到多个处理机上,实现并发执行,即利用每个处理机爱处理一个可并发执行的程序。这样,多个程序便可以同时执行。以此就能提高系统中的资源利用率,增加系统的吞吐量。

进程与线程

进程是指一个内存中运行的应用程序。一个应用程序可以同时启动多个进程,那么上面的问题就有了解决的思路:我们启动两个进程,一个用来打游戏,一个用来播放音乐。
这当然是一种解决方案,但是想象一下,如果一个应用程序需要执行的任务非常多,例如LOL游戏吧,光是需要播放的音乐就有非常多,人物本身的语音,技能的音效,游戏的背景音乐,塔攻击的声音等等等,还不用说游戏本身,就光播放音乐就需要创建许多许多的进程,而进程本身是一种非常消耗资源的东西,这样的设计显然是不合理的。更何况大多数的操作系统都不需要一个进程访问其他进程的内存空间,也就是说,进程之间的通信很不方便,此时我们就得引入“线程”这门技术,来解决这个问题。

线程是指进程中的一个执行任务(控制单元),一个进程可以同时并发运行多个线程。打开我们的任务管理器,在【查看】里面点击【选择列】,有一个线程数的勾选项,找到并勾选,可以看到:
Java重新出发--Java学习笔记(九)--并发基础_第1张图片
image.png

多任务系统,该系统可以运行多个进程,一个进程可以执行多个任务,一个进程可以包含多个线程。
当然一个进程中至少有一个线程,为了提高效率可以在一个进程中开启多个执行任务,也就是多线程。

程和线程的区别:

进程:有独立的内存空间,进程中的数据存放空间(堆空间和栈空间)是独立的,至少有一个线程。
线程:堆空间是共享的,栈空间是独立的,线程消耗的资源也比进程小,相互之间可以影响的,又称为轻型进程或进程元。

因为一个进程中的多个线程是并发运行的,那么从微观角度上肯定也是有先后顺序的,哪个线程来执行是由CPU调度器,也就是JVM来决定的,程序员无法控制。我们可以把多线程并发性看作是多个线程在瞬间抢占CPU资源,谁抢到资源谁就运行,这也就造就了多线程的随机性。

java程序的进程里至少包含主线程和垃圾回收线程(后台线程)。你可以简单的这样认为,但实际上有四个线程(了解就好):
[1] main——main线程,用户程序入口
[2] Reference Handler——清除Reference的线程
[3] Finalizer——调用对象finalize方法的线程
[4] Signal Dispatcher——分发处理发送给JVM信号的线程

多线程的优势

尽管面临很多挑战,多线程有一些优点使得它一直被使用。这些优点是:

  1. 资源利用率高
  2. 程序涉及在某些情况下更简单
  3. 程序响应更快。

(1)资源利用率高
想象一下,一个应用程序需要从本地文件系统中读取和处理文件的情景。比方说,从磁盘读取一个文件需要5秒,处理一个文件需要2秒。处理两个文件则需要:

1| 5秒读取文件A
2| 2秒处理文件A
3| 5秒读取文件B
4| 2秒处理文件B
5| ---------------------
6| 总共需要14秒

从磁盘中读取文件的时候,大部分的CPU时间用于等待磁盘去读取数据。在这段时间里,CPU非常的空闲。它可以做一些别的事情,通过改变操作的顺序,就能够很好的使用CPU资源。看下面的顺序:

1| 5秒读取文件A
2| 5秒读取文件B + 2秒处理文件A
3| 2秒处理文件B
4| ---------------------
5| 总共需要12秒

CPU等待第一个文件被读取完。然后开始读取第二个文件。当第二文件在被读取的时候,CPU会去处理第一个文件。记住,在等待磁盘读取文件的时候,CPU大部分时间是空闲的。
总的来说,CPU能够在等待IO的时候做一些事情。这个不一定是磁盘IO,它也可以是网络的IO,或者用户输入。

(2)程序设计更加简单
在单线程应用程序中,如果你想编写程序手动处理上面所提到的读取和处理的顺序,你必须记录每个文件读取和处理。
相反,其实可以启动两个线程,每个线程处理一个文件的读取和操作,线程会在等待磁盘读取文件的过程中被阻塞。
在等待的时间内,其他的线程能够使用CPU去处理已经读取完的文件。其结果就是,磁盘总时在繁忙的读取不同的文件到内存中。这会带来磁盘和CPU利用率的提升。而且每个线程只需要记录一个文件,因此这种方式也较容易编程实现。

(3)程序响应更快
有时我们会编写一些较为复杂的代码(不一定是算法复杂,也可能是业务逻辑复杂),例如一笔订单的创建,它包括插入订单数据,生成订单快照,发送邮件通知卖家和记录货品销售数量等。
用户从单击“订购”按钮开始,就要等待这些操作全部完成才能看到订购成功的结果。但是这么多业务操作,如何能够让其更快地完成呢?
在上面描述的场景中,我们可以使用多线程技术,将数据一致性不强的操作派发给其他线程处理(也可以使用消息队列),如生成订单快照,发送邮件等。这样做是为了更快的响应用户线程,提高了用户体验。

多线程还有一些优势也显而易见:
进程之间不能共享内存,而线程之间共享内存(堆内存)则很简单。
系统创建进程时需要为该进程重新分配系统资源,创建线程则代价小很多,因此实现多任务并发时,多线程效率较高。
java语言本身内置多线程功能的支持,而不是单纯的作为底层系统的调度方式,从而简化了多线程变成。

上下文切换
即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停地切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。
CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务的时候,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。

这就像我们同时读两本书,当我们在读一本英文的技术书时,发现某个单词不认识,于是打开中英文字典,但是在放下英文技术书之前,大脑必须先记住这本书独到了多少页的多少行,等查完单词之后,能够继续读这本书。这样的切换是会影响读书效率的,同样上下文切换也会影响多线程的执行速度。

创建线程的两种方式

(1)继承Thread类:

class PlayMusicThread extends Thread{
    private int playTime = 50;//播放时间,用循环来模拟播放过程
    public void run(){
        for(int i = 0;i < playTime; i++){
            System.out.println("播放音乐"+i);
        }
    }
}
    public static void main(String[] args){
        //主线程运行游戏
        for(int i = 0; i<50;i++){
            System.out.println("打游戏"+i);
            if(i == 10){
                PlayMusicThread musicThread = new PlayMusicThread();
                musicThread.start();
            }
        }
    }

运行结果发现打游戏和播放音乐交替出现,说明已经成功了。

实现Runnable接口也能实现,使用匿名内部类的方式。

class PlayGameThread implements Runnable{
    private int playTime = 50;//播放时间,用循环来模拟播放过程
    @Override
    public void run() {
        // TODO Auto-generated method stub
        for(int i = 0;i < playTime; i++){
            System.out.println("打游戏"+i);
        }
    }
    
}
public class Tester {
    public static void main(String[] args) {
        // TODO Auto-generated method stub
        
        //主线程播放音乐
        for(int i = 0; i<50;i++){
            System.out.println("播放音乐"+i);
            if(i == 10){
                Runnable target =new PlayGameThread();
                Thread t= new Thread(target);
                t.start();
            }
        }
    }
}

以上就是两种传统的创建线程的方式,事实上还有第三种,放到后面讨论。

多线程一定快吗?

先来一段代码,通过并行和串行来分别执行累加操作,分析:下面的代码并发执行一定比串行执行快吗?

//比较并行和串行累加操作的速度
public class Tester2 {
    private static final long count = 1000000;//执行次数
    
    public static void main(String[] args) throws InterruptedException {
        concurrency();
        serial();
    }
    
    private static void concurrency() throws InterruptedException{
        long start = System.currentTimeMillis();
        
        //通过匿名内部类来创建线程
        Thread thread = new Thread(new Runnable(){

            @Override
            public void run() {
                // TODO Auto-generated method stub
                int a = 0;
                for(long i = 0; i < count;i++){
                    a+=5;
                }
            }
            
        });
        
        thread.start();
        
        //并行执行
        int b = 0;
        for(long i = 0;i < count;i++){
            b--;
        }
        
        thread.join();//等待线程结束
        long time = System.currentTimeMillis() - start;
        System.out.println("concurrency:"+time+"ms");
    }
    
    private static void serial(){
        long start = System.currentTimeMillis();
        
        int a = 0;
        for(long i = 0;i < count;i++){
            a += 5;
        }
        //串行执行
        int b = 0;
        for(long i = 0;i < count;i++){
            b--;
        }
        
        long time = System.currentTimeMillis()-start;
        System.out.println("serial:"+time+"ms");
    }
}

把上述代码做运行,并改变计数次数,可以看出。当不超过1百万的时候,并行是明显比串行要慢的,为什么并发执行的速度会比串行慢呢?这是因为线程有创建和上下文切换的开销。

继承Thread类还是实现Runnable接口?

吃苹果比赛
想象一个这样的例子:给出一共50个苹果,让三个同学一起来吃,并且给苹果编上号码,让他们吃的时候顺便要说出苹果的编号:

//每个同学比作一个线程对象
public class Person extends Thread{
    private int num = 50;
    public Person(String name){
        //调用父类方法给线程取名字
        super(name);
    }
    public void run(){
        for(int i = 0;i<50;i++){
            if(num>0){
                System.out.println(super.getName()+"吃了编号为:"+num--+"的苹果");
            }
        }//end for
    }//end run
}
public class Tester50 {
    public static void main(String[] args) {
        //创建三个线程(同学),吃苹果
//      new Person("A").start();
//      new Person("B").start();
//      new Person("C").start();
    }
}
public class Apple implements Runnable{
    public int num = 50;//苹果总数
    
    @Override
    public  void run() {
        // TODO Auto-generated method stub
        for(int i = 0;i<50;i++){
            synchronized(this){
                if(num>0){
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        // TODO Auto-generated catch block
                        e.printStackTrace();
                    }//模拟网络延迟
                    System.out.println(Thread.currentThread().getName()+"吃了编号为:"+num--+"的苹果");
                }
            }
            
        }//end for
    }//end run
}

public class Tester50 {
    public static void main(String[] args) {
        //使用实现runnable的方式来实现
        Apple a = new Apple();
        new Thread(a,"A").start();
        new Thread(a,"B").start();
        new Thread(a,"C").start();
    }

}

运行结果可以看到,使用继承方式实现,每一个线程都吃了50个苹果。这样的结果显而易见:是因为显式地创建了三个不同的Person对象,而每个对象在堆空间中有独立的区域来保存定义好的50个苹果。
而使用实现方式则满足要求,这是因为三个线程共享了同一个Apple对象,而对象中的num数量是一定的。

所以可以简单总结出继承方式和实现方式的区别:
对于这两种方式其实也是各有优缺点,但是推荐使用Runnable方式,因为线程池可以有效的管理实现了Runnable接口的线程,如果线程池满了,新的线程就会排队等候,直到线程池空出来为止。而如果线程是通过实现Thread子类实现的,这将会复杂一些。
有时我们要同时融合实现Runnable接口和Thread子类两种方式。例如,实现了Thread子类的实例可以执行多个实现了Runnable接口的线程。一个典型的应用就是线程池。

常见的错误:调用run()方法而非start()方法
创建并运行一个线程所犯的常见错误是调用线程的run()方法而非start()方法,如下所示:

1| Thread newThread = new Thread(MyRunnable());
2| newThread.run();  //should be start();

起初你并不会感觉到有什么不妥,因为run()方法的确如你所愿的被调用了。但是,事实上,run()方法并非是由刚创建的新线程所执行的,而是被创建新线程的当前线程所执行了。
也就是被执行上面两行代码的线程所执行的。想要让创建的新线程执行run()方法,必须调用新线程的start方法。

吃苹果比赛的问题:线程不安全问题
尽管,Java并不保证线程的顺序执行,具有随机性,但吃苹果比赛的案例运行多次也并没有发现什么太大的问题。这并不是因为程序没有问题,而只是问题出现的不够明显,为了让问题更加明显,我们使用Thread.sleep()方法(经常用来模拟网络延迟)来让线程休息10ms,让其他线程去抢资源。(注意:在程序中并不是使用Thread.sleep(10)之后,程序才出现问题,而是使用之后,问题更明显.)

① A线程拿到了编号为48的苹果,打印输出然后让num减1,睡眠10ms,此时num为47。
② 这时B和C同时都拿到了编号为47的苹果,打印输出,在其中一个线程作出了减一操作的时候,A线程从睡眠中醒过来,拿到了编号为46的苹果,然后输出。

在这期间并没有任何操作不允许B和C线程不能拿到同一个编号的苹果,之前没有明显的错误仅仅可能只是因为运行速度太快了。

再来分析第二种错误:照理来说只应该存在1-50编号的苹果,可是0和-1是怎么出现的呢?
① 当num=1的时候,A,B,C三个线程同时进入了try语句进行睡眠。
② C线程先醒过来,输出了编号为1的苹果,然后让num减一,当C线程醒过来的时候发现num为0了。
③ A线程醒过来一看,0都没有了,只有-1了。

归根结底是因为没有任何操作来限制线程来获取相同的资源并对他们进行操作,这就造成了线程安全性问题。
如果我们把打印和减一操作分成两个步骤,会更加明显:
像这样的原子操作,是不允许分步骤进行的,必须保证同步进行。不然可能引发不可设想的后果。

你可能感兴趣的:(Java重新出发--Java学习笔记(九)--并发基础)