美文网首页
Java程序进阶课程学习(一)

Java程序进阶课程学习(一)

作者: MikeShine | 来源:发表于2020-02-01 16:09 被阅读0次

    写在前面

    继之前的 Java 的基础课程之上,来看下 Java 的进阶课程
    主要包括了 多线程的概念和一些虚拟机的知识。


    1. 线程基础知识

    第一章主要讲了一下线程的基础知识。
    重点是如何通过两种方法来创建线程;如何通过实现了Runnable的同一对象来完成多线程之间的数据共享。

    1.1 创建线程的两种方法

    1. 通过继承 Thread 类来创建
    这种方法代码编写简单,直接根据继承类来 new 一个对象就是一个新的线程。
    但是,由于 java 中无法支持多继承,该类无法在继承其他类。
    2. 通过实现Runnable接口来创建
    Runnable接口中只有一个 run() 方法,任何对该接口的实现都应该重写 run() 方法。在创建新的线城时候,调用 Thread(对象,名称) 这个构造函数来创建,其中对象是实现了 Runnable 的对象,名称是线程名称。

    创建之后,都要通过 start() 方法来启动线程。

    1.2 多线程之间的数据共享

    通过 实现了 Runnable接口的同一对象来完成。
    例如,现在编写了一个实现了Runnable接口的 TicketSeller 类,其中有一个数据成员 tickets 来代表剩余票数。
    此时,通过一个 TicketSeller 对象就可以创建多个不同的线程,这些线程共用 tickets 这个数据成员。

    public class TestMultiThread{
        public static void main(String args[]){
            TestThread test = new TestThread;   // 实现了 Runnable 接口的类
            
            // 下面就是用 实现了 Runnable 接口的同一个对象来 达到线程间共享数据的目的。
            new Thread(test,"Thread1").start()
            new Thread(test,"Thread2").start()
            new Thread(test,"Thread3").start()
        }
    }
    

    2. 线程同步与锁

    这一章主要讲了下线程之间如何同步,锁的机制以及线程调度等的知识。

    2.2 & 2.3 线程同步

    这里的同步是指,在多个线程操作同一个对象的一组数据时候,在某一个时间段内,我们想要的效果是,这个数据只能被一个线程来修改,其他线程等待。否则就会出现混乱的情况。
    这里在 2.2 节中作者就通过一个 存票 和 售票 两个线程来操作同一个 Ticket 对象来说明问题。
    问题存在
    在主函数中,如果先启动售票线程,再启动存票线程,那么运行结果,会是很乱,只要有存入的票号码,在其后面任何时候都有可能执行售票线程。

    因为此时没有同步机制,两个线程相当于竞争上岗,只要有票时候,售票线程就可以执行。这样的条件下,结果就很乱,每次运行都不同。

    而如果先启动存票线程,再启动售票线程,就不会有这样的问题。因为存票线程一旦启动,就会到线程执行结束,全部存完,之后才是售票线程。

    问题解决
    要解决这样的问题,就是用 synchronized 关键字来实现同步。

    • 同步代码块
    synchronized (对象) {代码块}
    

    作者首先给出了同步存票和售票线程代码快的同步方法,这种方法指定对象,获取对象的锁,从而执行代码块

    • 同步方法
      之后,作者给出了同步方法来达到同步目的。将存票和取票的操作写成方法集成在 Ticket 类中,并用 synchronized 修饰方法。在存票和售票线程调用相关的方法的时候,即为同步的。

    这种方法更为推荐。


    2.4 线程的等待与唤醒

    首先需要明白的是,这里的 等待 与 唤醒,其实都是线程间的,而线程是针对某个对象的,比如说对象 x。可以理解为线程间对话的方式。

    • wait()
      x对象的某个线程A调用 wait() 方法,该线程会释放对象的锁,并且进入对象x的等待池。直到x对象的其他线程来 调用 nofity()/notifyAll() 方法来唤醒,A线程才可以重新获得对象x的锁,并且从 wait() 语句后开始执行。
    • notify()
      随即唤醒一个等待的线程,本线程还继续执行。

    其实就是唤醒一下别的线程,自己该干嘛,继续干嘛

    • notifyAll()
      唤醒所有等待的线程

    这里作者给出了一个例子,把之前提到的存票售票过程,要改成,存一张票,就卖一张票。即通过两个同步方法的互相唤醒和等待来实现。

    package MultipleThreads;
    
    //  用于研究多线程的同步控制
    
    public class Tickets {
        int number = 0; // 存票序号
        int i = 0; // 售票序号
        int size; // 总数
        boolean available = false; // 目前是否有票在售
    
        public Tickets(int size) {
            this.size = size;
        }
        public synchronized void put() {
            if(available){//  如果有票可售,则存票线程等待
                try {wait();
                }catch (Exception e){}
            }
            System.out.println("Producer puts ticket " + (++number));
            available = true;
            notify(); // 存票后唤醒售票线程,因为这里只有两个线程
        }
    
        public synchronized void sell() {
            if(!available)  // 没有存票可售,则售票线程等待
                try {wait();} catch (Exception e){}
            System.out.println("Consumer buys ticket "+(number));
            available = false;
            notify(); // 售票后唤醒存票线程
            if(number==size) number = size+1; // 售完,设置一个结束标志,
            // number> size 表示售票结束.  为了使得 put() & sell() 方法结束
        }
    }
    

    2.5 后台线程

    后台线程也叫守护线程(Daemon Thread)。与之对应的概念是用户线程(User Thread)。
    这里这一节里面,作者只是介绍了几个简单的概念。

    • 一般后后台进程都是辅助其他进程的。(比如 java 著名的 “垃圾回收”就是一个后台线程)
    • 后台线程不妨碍程序终止。没有前台线程的时候程序才会终止。
    • 在 start() 一个进程之前,调用 setDaemon(true) 即可。

    2.6 线程的生命周期和死锁

    生命周期

    首先作者介绍了一下,线程的生命周期,即一个线程从被创建到死亡,其可能所处的生命状态。


    线程的生命周期状态图
    死锁

    如果线程之间相互等待,都不能获得对象的锁的时候,那么就称作此时陷入了死锁的情况下。
    这里作者给出了一个拿球游戏的例子,有三个玩家,在某种情况下,就会陷入,三个玩家都等待左边的球,从而三个线程都在等待获得锁,即进入了死锁状态。
    对于死锁问题,我们要做好预防,一旦发生之后,调试是很难的。

    控制线程生命周期

    这里作者提到,如果想要结束一个线程,一般不推荐我们使用提供的 stop() 方法(这种情况下可能会意外结束线程,造成数据段不完整等未知错误),而是在 run() 方法中通过某种循环条件的达到,来结束 run() 方法,从而自动的结束。


    2.7 线程的调度

    这一节说的线程的调度,实际上是指,当多个线程存在的时候,JVM虚拟机如何调用不同的线程。
    事实上,JVM执行 固定优先级算法,即按照线程的优先级来确定先执行哪个线程。同优先级,随机执行。
    通过 setPriority() 方法来给定线程的优先级,默认的是5,数字大说明优先级高。
    作者给出了一个例子,通过两个自增数列的线程来说明情况。当将两个优先级设定为不同的时候,高优先级的线程不会应为 yield() 方法而失去执行,因为 yield() 方法只是放弃执行权,此时由JVM执行判断,由于优先级较高,还是继续执行下去
    此时如果想要让高优先级的线程放弃执行,需要用 sleep()方法。


    3. 线程安全和锁优化

    这一章主要讲述关于线程安全的一些知识。
    线程安全是描述 代码 或者 某个类的特性。是说这段代码、这个类是线程安全的。

    3.2 线程的安全与线程兼容和对立

    Java 之中,线程安全分为4种:不可变、绝对线程安全、相对线程安全、线程兼容和对立

    不可变

    如果线程访问的数据或者类对象本来就是不可变的对象时候,那么这样的线程就是安全的。

    • final 修饰的
    • String 类(是一个常量类)
    • 枚举类
    • java.lang.Number 的子类,如 Long, Double 类
    • BigInteger, BigDecimal类(数据类型的高精度实现)
    绝对线程安全

    多线程访问同一个对象,不需要我们进行任何的额外同步的操作,最终都可以获得正确的结果,就是绝对线程安全的。

    相对线程安全

    通常意义上的线程安全,可以保证对象的单独操作是线程安全的。但是有些特定顺序的连续调用,需要我们手动添加同步手段。
    比如 Vector 和 HashTable 类,就是相对线程安全的。
    这里作者给出了一个例子,用来说明在连续访问 Vector 对象内元素的时候,如果没有同步机制,那么有时候是会出错的。所以需要用 Synchronized 关键字来同步连续调用的部分。

    线程兼容和线程对立
    • 线程兼容:对象本身不是线程安全的,但是调用时候通过一定的同步手段,即可在并发环境中安全使用
    • 线程对立:不管怎么,都无法在并发中安全使用。比如 Thread.suspend() & resume() 方法。

    3.3 实现线程安全--互斥同步(阻塞同步)

    这里的互斥同步,就是我们之前理解的那种同步。两种方法可以实现互斥同步:Synchronized 关键字 & ReentrantLock。前者是原生语法的互斥锁,后者是通过API的互斥锁。从实现的效率来看,重入锁效率会好一点。

    Synchronized 关键字

    就是我们之前说的那样。在经过编译之后,JVM会在同步块前后形成 monitorenter & monitorexit 两个字节码,前者把锁的计数器加1,后者把锁的计数器减1,在锁的计数器为0时候,锁被释放。

    ReentrantLock 重入锁

    相比于上面,重入锁可以实现:等待可中断、公平锁、锁绑定多个条件。

    • 等待可中断:就是说在等待锁的时候,等待的线程可以放弃等待,改为处理其他事情。自然这种对于等待时间很长的同步块来说很有帮助
    • 公平锁:多线程等待锁,必须按照申请锁的时间来排序。
    • 锁绑定多个条件: 暂时看不懂这个

    ReentrantLock 的逻辑很简单,就是在需要同步的代码块之前,调用 lock() 方法上锁;在之后,调用 unlock() 方法解锁。这里具体的介绍比较少,可以参考一下这个API文档。


    3.4 实现线程安全-- 非阻塞同步

    这里的阻塞不阻塞,是针对线程说的。就是说阻塞不阻塞线程。等不等待锁。

    上面的互斥同步(阻塞同步),是一种悲观的并发策略,即认为只要不去做正确的同步措施(加锁),那么就一定会出问题。
    这种同步方法最主要的问题是,进行线程阻塞和唤醒所需要的代价太大,需要操作系统完成,需要从用户态转到内核态,非常消耗时间。
    因此,就有了乐观的,非阻塞同步出现。这种方法认为,先进行操作,如果没有其他线程使用共享数据,那么操作就成功了;否则就有冲突,这时候不断重复直到成功为止。可以看出,这种策略不需要把线程挂起,也就是非阻塞同步。
    这里的非阻塞同步是通过硬件处理器指令不断重试策略完成的:

    • 测试并设置(Test-and-Set)
    • 获取并增加(Fetch-and-Increment)
    • 交换(Swap)
    • 比较并交换(Compare-and-Swap, CAS)
    • 加载连接,条件存储(Load-Linked, Store-conditional,LL, SC)
      有一些类已经实现了非阻塞同步。比如 AtomicInteger, AtomicBoolean 等。我们借助这些类,就可以实现非阻塞同步。
      作者给出了一个 CAS 方式的例子。
    public class Counter {
        private AtomicInteger count = new AtomicInteger();
        public synchronized void increament(){
            count.incrementAndGet();
        }
    
        public int getCount() {
            return count.get();
        }
    }
    

    阻塞方案和无阻塞方案,都是同步方案。可以这么理解,阻塞方案就是把同步的实现放在自己编写的对象中,而无阻塞方案就是把同步的实现放在了我们直接使用的类中,比如上面用的 AtomicInteger类。


    3.5 实现线程安全--无同步方案

    除了上面说的两种阻塞同步和无阻塞同步之外,实现线程安全,还可以使用无同步方案。
    无同步方案有两种实现形式:可重入代码 & 线程本地存储

    可重入代码(纯代码)

    可重入代码:在代码执行过程中加入中断,转而执行另外一段代码,控制权返回后,原来的程序没有错误。
    可重入代码都是线程安全的。但是线程安全的代码并不一定都是可重入的。
    判断可重入性:一个方法,输入了相同的数据,返回结果是相同的,那就是可重入的。

    线程本地存储

    核心思想是:如果代码中的数据必须与其他代码共享,那么就看这些共享数据的代码能否在同一个线程中执行。这样不用同步,也可以避免线程间数据争用问题。一般都是通过 ThreadLocal类来实现线程本地存储的。
    这里作者给出了一个例子,通过匿名类定义ThreadLocal 的子类,重写initialValue() 方法提供初始变量值。

    **
     * 本地存储来实现线程安全性
     */
    
    public class SequenceNumber {
        // 匿名类,重写 ThreadLocal 内的 initialValue() 方法
        private static ThreadLocal<Integer> seqNum = new ThreadLocal<Integer>(){
            public Integer intialValue(){
                return 0;
            }
        };
        public int getNextNum(){
            // 获取下一个序列值
            seqNum.set(seqNum.get()+1);
            return seqNum.get();
        }
        public static void main(String args[]){
            SequenceNumber sn = new SequenceNumber();
            TestClient t1 = new TestClient(sn);
            TestClient t2 = new TestClient(sn);
            TestClient t3 = new TestClient(sn);
            t1.start();
            t2.start();
            t3.start();
        }
    
        /**
         * 内部线程类
         */
        private static class TestClient extends Thread{
            private SequenceNumber sn;
            public TestClient(SequenceNumber sn){
                this.sn = sn;
            }
    
            @Override
            public void run() {
                for(int i=0;i<3;i++){
                    System.out.println("线程["+ Thread.currentThread().getName()+"]sn["+sn.getNextNum()+"]");
                }
            }
        }
    }
    

    3.6 锁优化

    很多时候,必须加锁,那么这时候是否能对锁进行一定的优化,使得程序性能得以提高呢。这就是我们锁优化的出发点。
    这里包含了5个锁优化方式:自旋锁、自适应锁、锁消除、锁粗化、偏向锁

    自旋锁

    之前我们提到过,由于线程的挂起和重新唤醒是要交由操作系统,浪费很多时间。于是,第一种锁优化,自旋锁便应运而生。其核心思想很简单,就是说,在新的进程到来之后,如果锁还在其他进程那里。这时候,不将新的进程挂起,而是让其进入循环等待(自旋),这样使得该线程一直处于 active,不用挂起。

    自适应锁

    在自旋锁的基础上,如果自旋时间不再固定,而是根据上一次的在同一个锁上自旋的时间以及锁拥有者的状态来决定。
    比如说,同一个锁对象上,上一次自旋刚刚成功获得锁,并且持有锁的线程正在运行中,那JVM就认为这次自旋也很有可能成功,则允许自旋更长时间。

    锁消除

    通俗来说,就是你写代码的时候,加锁了,但是 JVM 编译之后,认为没有共享数据是被竞争的,于是,你代码中写的锁就被消除了。
    而JVM 是如何判断的呢?堆上的数据不会逃逸而被其他线程访问到,则可以把它们当做栈上数据对待,认为是进程私有的,同步加锁自然无需进行。

    锁粗化

    频繁的加锁带来的互斥同步,会带来性能的损耗。因此,我们可以考虑,在写代码的时候,将加锁的范围扩大,包含更多的代码,使得互斥同步的频率减少。

    偏向锁

    如果无竞争情况下,一个同步操作也是需要两次锁操作,lock & unlock 才能获取资源,为了避免这种情况下性能的下降,就有了偏向锁。
    所谓偏向,就是锁偏向于第一个获取资源的线程,如果接下里的程序中没有其他的线程获取这个锁,那么持有偏向锁的线程永远不做任何同步(不用再无用的上锁,解锁)。

    这一个小节讲的都是一些概念,没有说具体的实现是怎么样的。
    现有一个大概的了解吧。


    逃逸分析

    所谓逃逸分析,就是在分析对象的 动态作用域
    一个对象在方法中定义之后,可能被外部方法引用,成为方法逃逸;也可能被外部线程访问到,成为线程逃逸。

    • 栈上分配:当确定一个对象不会方法逃逸的时候,就可以在栈上分配这个对象的内存,这样随着方法的栈帧出栈,方法调用结束,那么对象销毁。这样减轻了垃圾回收的压力。
    • 同步消除:一个对象不会 线程逃逸的时候,那么对这个变量的同步操作就可以消除掉(就是我们上面所说的“锁消除””)
    • 标量替换
      标量:数据无法被分解为更小的数据,int、long、reference 都是标量
      聚合量:对象就是聚合量
      所以标量替换是说,一个对象,不会被外部访问,程序执行时可以不创建这个对象,改为创建其若干个被方法使用的成员变量(就是说,不被外部访问的时候,就可以创建他被拆开的量。)

    相关文章

      网友评论

          本文标题:Java程序进阶课程学习(一)

          本文链接:https://www.haomeiwen.com/subject/hmlxxhtx.html