不知道你是否思考过,每次我们在IDEA中右键Run Application启动主方法,假如程序运行正常,控制台也打印出了你所要打印的信息,在这个过程中你知道这台计算机上那些硬件及其软件都是以什么样的方式参与到这个过程吗?今天我们就分析梳理下。

案例

1. Jdk的安装目录如下

Java后端更新数据刷新前端 java如何刷新_JVM

2. 配置环境变量JAVA_HOM、classpath、Path。                                    

Java后端更新数据刷新前端 java如何刷新_java_02

3. 在Idea中编写Test类代码如下                                                                                    

/** * java代码运行流程演示 * @auther yangbp * @date 20201014 */public class Test {    private  boolean flag = true;    void run(){        System.out.println(" 线程开始 ");        while (flag) {        }        System.out.println(" 线程结束 ");    }    public static void main(String[] args) {        Test test = new Test();        new Thread(test::run).start();        try {            Thread.sleep(1000);        } catch (InterruptedException e) {            e.printStackTrace();        }        test.flag = false;    }}

4.运行结果如下

Java后端更新数据刷新前端 java如何刷新_java_03

思考

(1)JDK、JRE、JVM的区别?

    我们都知道JDK是java development kit 的缩写,意思是java开发工具包,而JRE是 java runtime environment 的缩写,意思是java运行环境,到此应该可以知道,如果我要开发java程序,那必须下载JDK,如果只是运行java 程序则只需安装JRE,但案例中,可以看到JDK的安装目录下包含了JRE。这是一个区别,那还有吗?如下图所示

Java后端更新数据刷新前端 java如何刷新_JVM_04

Java后端更新数据刷新前端 java如何刷新_java JLabel改变大小后如何刷新_05

可以看到jdk的bin目录下比jre的bin目录下多了一个javac应用程序,而我们都知道javac是用来把java类编译class(字节码)文件,java命令是来解释运行class文件,最终结果就是将class文件转化成机器码,那它又是咋样做到的,这就用到了JVM(java 虚拟机),从字面意思理解,虚拟机,那就是一个虚拟的计算机,所以它会尽量按照物理机的结构来虚拟出自己的结构。这个jvm就能将class文件转化成机器码供CPU执行。具体可参考官网这张图

Java后端更新数据刷新前端 java如何刷新_java JLabel改变大小后如何刷新_06

(2) 为什么需要配置JAVA_HOME、classpath、Path这些环境变量?

    如果不配置环境变量,那意味着你只能在jdk的bin目录下执行javac命令以及java命令。这样是不是很不方便,重要的是如何理解环境变量,顾名思义,就是一个变量而已,只不过我们可以认为它是全局的,只要配置了,你才可以在你计算机的任何位置执行一些java命令,比如我们配置了环境变量,当使用idea去运行程序时,首先它会在本地找javac应用程序,如果没有就会去Path变量中找。

(3)哈哈,大家肯定对程序运行的结果有些不解,但是造成这个结果的原因只有一个,也不知道和你想的是不是一样,稍后自懂,最终原因肯定是缓存一致性的问题。

分析Test类执行过程

    当我们在idea中右键点击Run Application后,首先会产生一个java.exe进程,这个进程是将Test.java编译成Test.class文件,然后会再生成一个java.exe负责执行Test.class文件。如下图所示

Java后端更新数据刷新前端 java如何刷新_java JLabel改变大小后如何刷新_07

Java后端更新数据刷新前端 java如何刷新_java JLabel改变大小后如何刷新_08

从任务管理器中,我们可以看到,8584这个java进程是负责编译的,而8776这个进程是负责执行class文件的。接下来我们主要分析8776这个进程的分配及执行。

梳理Test类详细执行过程

    假设Test.class是放在linux服务器硬盘上(idea也安装在服务器上),当我们在IDEA中点击Run Application的时候,Linux内核(在linux术语中,将linux操作系统称之为内核)会掌控一切,如下图所示

Java后端更新数据刷新前端 java如何刷新_JVM_09

图1

    Linux内核是硬件与软件的桥梁,应用程序只能经过linux内核来调用硬件,硬件的驱动都受到内核的管控。

Java后端更新数据刷新前端 java如何刷新_Java后端更新数据刷新前端_10

图2

从硬件角度来看,内存空间由两个部分组成,一个是主存,另一个是SWAP(从磁盘交换的空间),程序运行必须加载到主存中来,而当主存容量不够时,系统会把一部分主存上不用的数据放到磁盘上的SWAP中去,这样就可以腾出主存空间来运行现有程序,当需要用到SWAP中的数据时,再将其加载到主存中来。

从Linux操作系统来看,除了引导系统的BIN区,整个内存空间划分为内核内存(kernel space)和用户内存(user space)。内核内存负责自身使用的内存空间,主要提供给程序调度、内存分配、连接硬件资源等程序逻辑使用。用户内存是提供给各个进程的主要空间,Linux给各个进程提供相同的虚拟内存空间,所有进程都各自拥有4G的内存空间(虚拟内存),这使得进程之间相互独立,互不干扰。实现的方法是采用虚拟内存技术,给每一个进程一定的虚拟内存空间,而只有当虚拟内存实际被使用时,才分配物理内存。如下图所示,对于IA32的Linux来说,一般将0~3G的虚拟内存空间分配作为用户空间,将3~4G的虚拟内存空间划分为内核空间。

Java后端更新数据刷新前端 java如何刷新_java JLabel改变大小后如何刷新_11

图3

    从进程角度来看,进程能直接访问的用户内存(虚拟内存空间)被划分为五个部分:代码区、数据区、堆区、栈区、未使用区。代码区存放应用程序的机器代码,具有只读和固定大小的特点,数据区存放了应用程序中的全局数据、静态数据和一些常量字符串等,其大小也是固定的。堆是运行时程序动态申请的空间,属于程序运行时直接申请、释放的内存资源。栈区用来存放函数的传入参数、临时变量,以及返回地址等数据。未使用区是分配新内存空间的预备区域。

(一)进程与JVM进程

    JVM本质就是一个进程,因此其内存模型也有进程的一般特点,但是JVM又不是一个普通的进程,其在内存模型上有许多崭新的特点,主要原因有两个:(1)JVM将许多本来属于操作系统管理范畴的东西,移植到了JVM内部,目的在于减少系统调用的次数;(2)JAVA NIO,目的在于减少读写IO的系统调用的开销。如下图所示JVM进程与普通进程的内存模型比较

Java后端更新数据刷新前端 java如何刷新_java_12

图4

    值得注意的是,这个模型并不是JVM内存使用的精确模型,更侧重从操作系统的角度而省略了JVM的内部细节(尽管也很重要)下面从用户内存和内核内存说下JVM进程的内存特点。

(1)用户内存

        上图强调了JVM进程模型的代码区和数据区指的是JVM自身的,而非java程序的。普通进程栈区,在JVM一般仅仅用作线程栈。JVM的堆区和普通进程的差别特别大。

    永久代,本质上是JAVA程序的代码区和数据区,JAVA程序中类(class),也会被加载到整个区域的不同数据结构中去,包括常量池、域、方法数据、方法体、构造函数、以及类中的专用方法、实例初始化、接口初始化等。这个区域对于操作系统来说,是堆的一个部分,而对于java程序来说,这是容纳程序本身及静态资源的空间,使得JVM能够解释执行java程序。

    其次是新生代和老年代,新生代和老年代才是java程序真正使用的堆空间,主要用于内存对象的存储,但是其管理方式和普通进程有本质的区别,普通进程在运行时给内存对象分配空间时,比如C++执行new操作时,会触发一次分配内存空间的系统调用,由操作系统的线程根据对象的大小分配好内存空间后返回;同时,程序释放对象时,比如C++执行delete操作时,也会触发一次系统调用,通知操作系统对象所占用的空间已经可以回收了。

    JVM对内存的使用和一般进程不同,JVM向操作系统申请一整段内存区域(具体大小可在JVM参数调节)作为java程序的堆(分为新生代和老年代);当java程序申请内存空间,比如执行new操作,JVM将在这段空间中按需大小分配给java程序,并且java程序不负责通知JVM何时可以释放这个对象的空间,因为JVM有自己的垃圾回收器。

    JVM的内存管理方式优点显而易见,包括:第一,减少系统调用的次数,JVM在给java程序分配内存空间时不需要操作系统干预,仅仅在java堆大小变化时需要向操纵系统申请内存或通知回收,而普通程序每次内存空间的分配或通知回收都需要系统调用参与;第二,减少内存泄漏,普通进程没有(或者没有及时)通知操作系统内存空间的释放是内存泄漏的重要原因之一,而由JVM统一管理,可以避免程序猿带来的内存泄漏问题。

最后是未使用区,未使用区是分配新内存空间的预备区域。对于普通进程来说,这个区域被用于堆和栈空间的申请与释放,每次堆内存分配都会使用这个区域,因此大小变动频繁,对于JVM进程来说,调整堆大小及线程栈时会使用该区域,而堆大小一般较少调整,因此大小相对稳定。操作系统会动态调整这个区域的大小,并且这个区域通常并没有被实际分配的物理内存,只是允许进程在这个区域申请堆或栈空间。

(2) 内核内存

应用程序通常不直接和内核内存打交道,内核内存由操作系统进行管理和使用;不过随着Linux对性能的关注及改进,一些新特性使得应用程序可以使用内核内存,或者映射到内核空间,JAVA NIO正是在这个背景下产生的,这篇暂不说NIO。

(二)Java 虚拟机(JVM)

(1) JVM生命周期

    * 启动。启动一个java程序时,一个JVM实例就产生了,任何一个拥有public static void main(String[] args)函数的class都可以运行JVM实例。

    * 运行。main()作为该程序初始线程的起点,任何其他线程均由该线程启动

    * 消亡。当程序中的所有非空守护线程都终止时,JVM才退出;若安全管理器允许,程序也可以使用Runtime类或者system.exit来退出。

    一个运行中的java虚拟机有着一个清晰的任务;执行java程序,程序开始执行时他才运行,程序结束时他就停止。你在同一台机器上运行三个程序,就会有三个运行中的java虚拟机。虚拟机总是开始于一个main()方法,这个方法必须是公有、返回void、直接接受一个字符串数组。在程序执行时,你必须给JAVA虚拟机指明这个包下main()方法的类名。main()方法是程序的起点,他被执行的线程初始化为程序的初始线程。程序中其他的线程都由他来启动。

    Java中的线程分为两种:守护线程(daemon) 和 普通线程(non-daemon)。守护线程是java虚拟机自己使用的线程,比如负责垃圾收集的线程就是一个守护线程。当然,你也可以把自己的程序设置为守护线程。包含main()方法的初始线程不是守护线程。

    注意的是只要Java虚拟机中还有普通的线程在执行,JAVA虚拟机就不会停止。如果有足够的权限,你可以调用exit()方法终止程序。

(2)JVM体系结构

    1) 类装载器 (ClassLoader) 用来装载.class文件

    2)执行引擎 (执行字节码,或者执行本地方法)

    3)运行时数据区 (方法区、堆、java栈、PC寄存器、本地方法栈)

(3)JVM运行时数据区

Java后端更新数据刷新前端 java如何刷新_java 如何循环执行一个对象_13

图5

3.1 Java 堆(heap)

       * 被所有线程共享的一块内存区域,在虚拟机启动时创建

       * 用来存储对象实例

       * 可以通过-Xmx 和 -Xms控制堆的大小

       * OutOfMemoryError异常:当在堆中没有内存完成实例分配,且堆也无法再扩展时。

    java堆是垃圾回收器管理的主要区域。java堆还可以细分为:新生代(New/Young)、旧生代/年老代(Old/Tenured)。持久代(Permanent)在方法区,不属于Heap。

Java后端更新数据刷新前端 java如何刷新_JVM_14

图6

新生代:新建的对象都有新生代分配内存。常常又被分为Eden区和Survivor区。Eden空间不足时会把存活的对象转移到Survivor。新生代的大小可由-Xmn控制,也可用-XX:SurvivorRatio控制Eden和Survivor的比例。

旧生代:存放经过多次垃圾回收仍然存活的对象。

持久代:存放静态文件,如今Java类、方法等。持久代在方法区,对垃圾回收没有显著影响。

3.2 方法区

    * 线程间共享

    * 用于存储已被虚拟机加载的类信息、常量、静态变量、即使编译器编译后的代码等数据。

    * OutOfMemoryError异常:当方法区无法满足内存的分配需求时

【运行时常量池】

    * 方法区的一部分

    * 用于存放编译期生成的各种字面量与符号引用,如String类型常量就存放在常量池

    * OutOfMemoryError异常:当常量池无法再申请到内存时。

3.3 Java 虚拟机栈(vm stack)

    * 线程私有,生命周期与线程相同 

    * 存储方法的局部变量表(基本类型、对象引用)、操作数栈、动态链接、方法出口等信息。

    * java方法执行的内存模型,每个方法执行的同时都会创建一个栈帧,每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程

    * StackOverFlowError异常:当线程请求的栈深度大于虚拟机所允许的深度

    * OutOfMemoryError异常:如果栈的扩展时无法申请到足够的内存

    JVM栈是线程私有的,每个线程创建的同时都会创建JVM栈,JVM栈中存放的为当前线程中局部基本类型的变量、部分的返回结果以及Stack Frame。其他引用类型的对象在JVM栈上仅存放变量名和指向堆上对象实例的首地址

3.4 本地方法栈(Native Method stack)

    与虚拟机栈相似,主要为虚拟机使用到Native方法服务,在HotSpot虚拟机中直接把本地方法栈与虚拟机栈二合一。

3.5 程序计数器(Program Counter Register)

    * 当前线程所执行的字节码的行号指示器

    * 当前线程私有

    * 不会出现OutOfMemoryError情况

3.6 直接内存 (Direct Memory)

  * 直接内存并不是虚拟机运行的一部分,也不是java虚拟机规范中定义的内存区域,但是这部分内存也被频繁使用

  * NIO可以使用Native函数库直接分配堆外内存,堆中的DirectByteBuffer对象作为这块内存的引用进行操作。

  * 大小不受Java堆大小的限制,受本机(服务器)内存限制

  * OutOfMemoryError异常:系统内存不足时。

总结:java对象实例存放在堆中;常量存放在方法区的常量池;虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据存放在方法区;以上区域是所有线程共享的。栈是线程私有的,存放该方法的局部变量表(基本类型、对象引用)、操作数栈、动态链接、方法出口等信息。

    一个java程序对应一个JVM,一个线程对应一个java栈。

(4)JVM执行过程

Java后端更新数据刷新前端 java如何刷新_java_15

图7

(四)进程的创建

(1) 基本概念

4.1.1 进程和线程

* 进程是系统资源分配的基本单位,线程是程序独立运行的基本单位

* 线程有时候也被称作小型进程,首先,多个线程之间是可以共享资源的;其次,多个线程之间的切换所花费的代价远远比进程低

4.1.2 轻量级进程

    * 在Linux内核中并不存在线程这个概念,内核对线程并没有设立特别的数据结构,而是与进程一样使用task_struct结构进行描述。

    * 线程在内核中以一个进程的形式而存在的,只不过它比较特殊,它和同类的进程共享某些资源,比如进程地址空间,进程的信号,打开的文件等。这类互相共享资源的进程称之为轻量级进程(Light Weight Process, LWP)

    * 以此策略实现的用户态线程都和内核中一个轻量级进程相对应,多个轻量级进程之间共享资源,从而体现了多线程之间资源共享的特性。同时这些轻量级进程跟普通进程一样由内核进行调度,从而实现了多个进程之间的并发执行。 

4.1.3 POSIX线程库

    POSIX ( Portable  Operating System Interface)便携式操作系统接口,用户级线程和内核中轻量级进程的关联是在符合POSIX标准的线程库中完成的。目前内核支持的线程库为NPTL,即Native Posix Thread library。

4.1.4 线程组

    * POSIX 标准规定在一个多线程的应用程序中,所有线程都必须具有相同的PID。

    * 从线程在内核中的实现可得知,每个线程其实都有自己的PID,为了遵循POSIX标准,Linux引入了线程组的概念,在一个多线程的程序中,所有线程都形成一个线程组。

    * 一个线程组中第一个轻量级进程的PID即为该线程组的PID。通常,每一个线程由主线程创建的,主线程即为调用pthread_create()的线程,因此该线程组中所有线程的PID即为主线程PID。

4.1.5 内核线程

    在内核中有一种特殊的线程,称之为内核线程(kernel thread),内核线程在内核中也是通过task_struct结构体来表示的,内核线程和普通进程一样也是内核调度的实体,但是有以下区别:

    * 内核线程永远都运行在内核态,而进程既可以运行在内核态也可以运行在用户态。

    * 内核线程只能调用内核函数,而普通进程只能通过系统调用才能使用内核函数。

4.1.5 进程描述符

    内核中使用数据结构task_struct来表示进程,该结构即为所谓的进程描述符,它的字段包含了与一个进程相关的所有信息。不仅包含了许多描述进程属性的字段,而且还有一系列指向其他数据结构的指针,其中一部分指向代表进程所拥有的资源的数据结构。

4.1.6 进程地址空间布局图

Java后端更新数据刷新前端 java如何刷新_Java后端更新数据刷新前端_16

图8

    Linux把进程的用户空间划分为若干个区间,便于管理,这些区间称为虚拟内存区域(简称VMA)。一个进程的用户地址空间主要由mm_struct结构和vm_area_structs结构来描述。mm_struct结构对进程整个用户空间进行描述,m_area_structs结构对用户空间中各个内存区进行描述。进程相关数据结构如下

Java后端更新数据刷新前端 java如何刷新_java JLabel改变大小后如何刷新_17

4.1.7 新建虚拟内存区域

    在内核空间可以通过do_mmap()创建一个新的虚拟内存区域

    在用户空间可以通过mmap()系统调用获取do_mmap()的功能

    值得特别注意的是,这里有个内存映射知识点,何为内存映射?就是把文件从磁盘映射到进程用户空间的一个虚拟内存区域中,对文件的访问转化为对虚拟区的访问。当从这段内存中读数据时,就相当于读磁盘文件中的数据,将数据写入这段内存时,则相当于将数据直接写入磁盘文件。这样就可以在不使用基本I/O操作函数read和write的情况下执行I/O操作。

4.1.6 task_struct 

    在Linux内核中,内核将进程、线程和内核线程一视同仁,即内核使用唯一的数据结构task_struct来分别表示他们;内核使用相同的调度算法对这三者进行调度;并且他们在内核中通过do_fork()分别创建。

    这样处理对内核来说简单方便,内核在统一处理这三者之余并没有失去他们本身所具有的特性。

    对于普通进程来说,进程描述符中每个字段都代表进程的属性

    线程:进程A创建了线程B,则B线程会在内核中对应一个轻量级进程。这个轻量级进程很自然的对应一个进程描述符,只不过B线程的进程描述符中某些代表资源的指针会和A进程中对应的字段指向同一个数据结构,这样就实现了多线程之间的资源共享

    内核线程:由于内核线程只运行在内核态,并且只能由其他内核线程创建,所以内核线程并不需要和普通进程那样的独立地址空间。因此内核线程的进程描述符中的mm指针即为NULL,内核线程是否共享父内核线程的某些资源,则通过向内核线程创建函数kernel_thread()传递参数来决定。

4.1.6 进程API的实现

Java后端更新数据刷新前端 java如何刷新_java_18

图8

    进程、线程以及内核线程都有对应的系统调用,不过这三者在内核中最终都是由do_fork()进行创建的。这是怎么做到的?咋样区分呢?原因是do_fork()这个函数有很多的参数,根据传参的不同,就可以区分创建进程、线程、内核线程。值得注意的是fork()创建的进程不会共享父进程的任何资源,通常子进程会完全复制父进程的资源,也就是父子进程相对独立。vfork()函数已没有特别的使用之处。clone()通常用于创建轻量级进程。

4.1.7 线程与内核线程的创建

    * 每个用户态的线程在内核中对应一个轻量级进程,两者的关联是通过线程库完成的。

    * 一个新内核线程的创建是通过在现有的内核线程中使用kernel_thread()而创建的,其本质也是向do_fork()提供特定的flags标志而创建的。

    * 由于进程、线程和内核线程使用统一数据结构task_struct来表示,因此他们最终都被do_fork()创建,同时由于task_struct带来了统一性,linux内核也不会为其中某一个设立单独的调度算法,即进行统一的调度管理。

 4.1.8  进程调度

    多任务操作系统分为非抢占式多任务和抢占式多任务,与大多数操作系统一致,linux采用了抢占式多任务模式,意味着进程对CPU的占用时间由操作系统的调度器决定。 

(五)虚拟内存区域与物理内存的转换

        用户态的进程的程序经过编译执行形成进程,进程虽然可以任意访问整个用户空间的内存,但这毕竟属于虚拟地址空间,因此进程最终必须访问到物理内存。

        将虚拟内存和物理内存连接起来的就是分页机制,它在虚拟地址和物理地址之间建立了一种映射关系。

        物理内存管理机制。内核使用page结构体描述一个物理页框,该结构也称为页描述符,页框代表是物理内存的最小单位,内核通过算法来管理物理内存,(1)伙伴算法:负责大块连续物理内存的分配和释放,以页框为基本单位,可以避免外部碎片。(2)slab缓存:负责小块物理内存的分配,并且它也作为一个缓存,主要针对内核中经常分配并释放的对象。(3)per-CPU页框缓存:内核经常请求和释放单个页框,该缓存包含预先分配的页框,用于满足本地CPU发出的单一页框请求。

总结

    了解了这些以后(知识点有点多,不用记,你就只需在脑中好奇它是咋莫一步步被执行的,凡是能感到疑惑的,都有一个知识点或多个在等你探索),到底一行java代码是如何被计算机执行的?我们编译后的class文件放置在硬盘上,当我们在IDEA中右键Run Application的时候,Linux内核会掌控一切,首先Linux会为我们任务创建一个进程,那他是咋样创建的,刚我们说了,Linux最终会调用内核函数do_fork()为我们创建一个进程,用task_struct数据结构来表述这个进程的所有信息,默认会为这个进程分配4G的虚拟内存空间(内核空间1G,用户空间3G),因为刚也说了Linux会把这个进程用户空间分为若干个虚拟区域,用mm_struct结构和vm_area_structs来表示。每个虚拟内存区域Linux内核最终都会调用内核函数do_mmap()来新建虚拟内存区域(VMA),这里会有两个问题,第一个,内存映射,我们都知道内存映射可以避免内核使用基本的I/O读写操作,直接将新建的虚拟空间与硬盘空间对应。但是我们也知道在进程还未执行时,class文件的还是个字节码,到底这个顺序是咋样的?其实它是动态几乎可以认为是并行执行的,这个时候JVM虚拟机也实例化了,它的类加载系统会将硬盘上的class文件通过执行引擎加载到这个JVM进程的虚拟内存空间。第二个,虚拟内存空间与物理内存空间的对应,当进程动态执行的时候,内核会通过请页机制将虚拟内存和物理内存对应起来。整个进程的task_struct数据结构是存储在主存上的,当进程的数据段,代码段有值时,控制器就会开启工作,从内存取指、译指、执行,但此时控制器取到的都是虚拟地址,如何转换呢,如下图所示

Java后端更新数据刷新前端 java如何刷新_Java后端更新数据刷新前端_19

图9

        它会通过MMU(内存管理单元,上篇已说过)将虚拟地址给它,然后MMU将对应的物理地址给存储器,转换机制就是分页机制,CPU就会找到主存,将对应的数据指令加载到自己缓存中来,执行即可。大体一行java就是这样再计算机上执行的。

答疑

    回到最开始(3)这个问题上,为什么不打印“线程结束”呢,我在主线程中不是将共享变量flag设为false了吗?首先我们先看下我画的这张图

Java后端更新数据刷新前端 java如何刷新_Java后端更新数据刷新前端_20

假如服务器有两个CPU,每个CPU都有俩个核,我们的程序有两个线程(主线程,子线程,这里先不算jvm守护线程),因为每个线程占用cpu核的时间都是由内核对应的算法决定的,假如我们的主线程被CPU1的核1抢先执行,那它将会把flag=true加载到自己的缓存L1,l2,L3中,子线程被CPU1的核2执行,也会将flag=true加载到自己的缓存L1,L2,L3中,主线程创建了子线程,然后主线程又沉睡了1000毫秒,在这1000豪秒的时间内,子线程肯定已经执行很多次了,等主线程醒来将flag的值改为false,那又咋样!因为这时候因为子线程所在cpu中L1,L2已经有了flag的值,所以它不会再从主存加载flag的新值,所以造成了死循环,要想解决很简单,给这个表量设置一个标记,标记当这个变量的值有改变时,所有的CPU核都必须检查更新自己的缓存。而volatile关键字就可以做到,具体原因后面再细讲。