`
footman265
  • 浏览: 114330 次
  • 性别: Icon_minigender_1
  • 来自: 宁波
社区版块
存档分类
最新评论

Java内存模型4

    博客分类:
  • j2SE
阅读更多

2)内存栈(Stack):
  当一个新线程启动的时候,JVM会为Java线程创建每个线程的独立内存栈,如前所言Java的内存栈是由栈帧构成,栈帧本身处于游离状态,在JVM里面,栈帧的操作只有两种:出栈入栈。正在被线程执行的方法一般称为当前线程方法,而该方法的栈帧就称为当前帧,而在该方法内定义的类称为当前类,常量池也称为当前常量池。当执行一个方法如此的时候,JVM保留当前类和当前常量池的跟踪,当虚拟机遇到了存储在栈帧中的数据上的操作指令的时候,它就执行当前帧的操作。当一个线程调用某个Java方法时,虚拟机创建并且将一个新帧压入到内存堆栈中,而这个压入到内存栈中的帧成为当前栈帧,当该方法执行的时候,JVM使用内存栈来存储参数、局部变量、中间计算结果以及其他相关数据。方法在执行过程有可能因为两种方式而结束:如果一个方法返回完成就属于方法执行的正常结束,如果在这个过程抛出异常而结束,可以称为非正常结束,不论是正常结束还是异常结束,JVM都会弹出或者丢弃该栈帧,则上一帧的方法就成为了当前帧。
  在JVM中,Java线程的栈数据是属于某个线程独有的,其他的线程不能够修改或者通过其他方式来访问该线程的栈帧,正因为如此这种情况不用担心多线程同步访问Java的局部变量,当一个线程调用某个方法的时候,方法的局部变量是在方法内部进行的Java栈帧的存储,只有当前线程可以访问该局部变量,而其他线程不能随便访问该内存栈里面存储的数据。内存栈内的栈帧数据和方法区以及内存堆一样,Java栈的栈帧不需要分配在连续的堆栈内,或者说它们可能是在堆,或者两者组合分配,实际数据用于表示Java堆栈和栈帧结构是JVM本身的设计结构决定的,而且在编程过程可以允许程序员指定一个用于Java堆栈的初始大小以及最大、最小尺寸。
  【概念区分】
  • 内存栈:这里的内存栈和物理结构内存堆栈有点点区别,是内存里面数据存储的一种抽象数据结构。从操作系统上讲,在程序执行过程对内存的使用本身常用的数据结构就是内存堆栈,而这里的内存堆栈指代的就是JVM在使用内存过程整个内存的存储结构,多指内存的物理结构,而Java内存栈不是指代的一个物理结构,更多的时候指代的是一个抽象结构,就是符合JVM语言规范的内存栈的一个抽象结构。因为物理内存堆栈结构和Java内存栈的抽象模型结构本身比较相似,所以我们在学习过程就正常把这两种结构放在一起考虑了,而且二者除了概念上有一点点小的区别,理解成为一种结构对于初学者也未尝不可,所以实际上也可以觉得二者没有太大的本质区别。但是在学习的时候最好分清楚内存堆栈和Java内存栈的一小点细微的差距,前者是物理概念和本身模型,后者是抽象概念和本身模型的一个共同体。而内存堆栈更多的说法可以理解为一个内存块,因为内存块可以通过索引和指针进行数据结构的组合,内存栈就是内存块针对数据结构的一种表示,而内存堆则是内存块的另外一种数据结构的表示,这样理解更容易区分内存栈内存堆栈(内存块)的概念。
  • 栈帧:栈帧是内存栈里面的最小单位,指的是内存栈里面每一个最小内存存储单元,它针对内存栈仅仅做了两个操作:入栈和出栈,一般情况下:所说的堆栈帧栈帧倒是一个概念,所以在理解上记得加以区分
  • 内存堆:这里的内存堆和内存栈是相对应的,其实内存堆里面的数据也是存储在系统内存堆栈里面的,只是它使用了另外一种方式来进行堆里面内存的管理,而本章题目要讲到的就是Java语言本身的内存堆和内存栈,而这两个概念都是抽象的概念模型,而且是相对的。
  栈帧:栈帧主要包括三个部分:局部变量操作数栈帧(操作帧)帧数据(数据帧)。本地变量和操作数帧的大小取决于需要,这些大小是在编译时就决定的,并且在每个方法的类文件数据中进行分配,帧的数据大小则不一样,它虽然也是在编译时就决定的但是它的大小和本身代码实现有关。当JVM调用一个Java方法的时候,它会检查类的数据来确定在本地变量和操作方法要求的栈大小,它计算该方法所需要的内存大小,然后将这些数据分配好内存空间压入到内存堆栈中。
  栈帧——局部变量局部变量是以Java栈帧组合成为的一个以零为基的数组,使用局部变量的时候使用的实际上是一个包含了0的一个基于索引的数组结构int类型float引用以及返回值都占据了一个数组中的局部变量的条目,而byteshortchar则在存储到局部变量的时候是先转化成为int再进行操作的,则longdouble则是在这样一个数组里面使用了两个元素的空间大小,在局部变量里面存储基本数据类型的时候使用的就是这样的结构。举个例子:
class Example3a{
    public static int runClassMethod(int i,long l,float f,double d,Object o,byte b)
    {
        return 0;
    }
    public int runInstanceMethod(char c,double d,short s,boolean b)
    {
        return 0;
    }
}
  栈帧——操作帧和局部变量一样,操作帧也是一组有组织的数组的存储结构,但是和局部变量不一样的是这个不是通过数组的索引访问的,而是直接进行的入栈和出栈的操作,当操作指令直接压入了操作栈帧过后,从栈帧里面出来的数据会直接在出栈的时候被读取使用。除了程序计数器以外,操作帧也是可以直接被指令访问到的,JVM里面没有寄存器。处理操作帧的时候Java虚拟机是基于内存栈的而不是基于寄存器的,因为它在操作过程是直接对内存栈进行操作而不是针对寄存器进行操作。而JVM内部的指令也可以来源于其他地方比如紧接着操作符以及操作数的字节码流或者直接从常量池里面进行操作。JVM指令其实真正在操作过程的焦点是集中在内存栈栈帧的操作帧上的。JVM指令将操作帧作为一个工作空间,有许多指令都是从操作帧里面出栈读取的,对指令进行操作过后将操作帧的计算结果重新压入内存堆栈内。比如iadd指令将两个整数压入到操作帧里面,然后将两个操作数进行相加,相加的时候从内存栈里面读取两个操作数的值,然后进行运算,最后将运算结果重新存入到内存堆栈里面。举个简单的例子:
begin
iload_0 //将整数类型的局部变量0压入到内存栈里面
iload_1 //将整数类型的局部变量1压入到内存栈里面
iadd     //将两个变量出栈读取,然后进行相加操作,将结果重新压入栈中
istore_2 //将最终输出结果放在另外一个局部变量里面
end
  综上所述,就是整个计算过程针对内存的一些操作内容,而整体的结构可以用下图来描述:
  栈帧——数据帧:除了局部变量和操作帧以外,Java栈帧还包括了数据帧,用于支持常量池、普通的方法返回以及异常抛出等,这些数据都是存储在Java内存栈帧的数据帧中的。很多JVM的指令集实际上使用的都是常量池里面的一些条目,一些指令,只是把int、long、float、double或者String从常量池里面压入到Java栈帧的操作帧上边,一些指令使用常量池来管理类或者数组的实例化操作、字段的访问控制、或者方法的调用,其他的指令就用来决定常量池条目中记录的某一特定对象是否某一类或者常量池项中指定的接口。常量池会判断类型、字段、方法、类、接口、类字段以及引用是如何在JVM进行符号化描述,而这个过程由JVM本身进行对应的判断。这里就可以理解JVM如何来判断我们通常说的:“原始变量存储在内存栈上,而引用的对象存储在内存堆上边。”除了常量池判断帧数据符号化描述特性以外,这些数据帧必须在JVM正常执行或者异常执行过程辅助它进行处理操作。如果一个方法是正常结束的,JVM必须恢复栈帧调用方法的数据帧,而且必须设置PC寄存器指向调用方法后边等待的指令完成该调用方法的位置。如果该方法存在返回值,JVM也必须将这个值压入到操作帧里面以提供给需要这些数据的方法进行调用。不仅仅如此,数据帧也必须提供一个方法调用的异常表,当JVM在方法中抛出异常而非正常结束的时候,该异常表就用来存放异常信息。
  3)内存堆(Heap):
  当一个Java应用程序在运行的时候在程序中创建一个对象或者一个数组的时候,JVM会针对该对象和数组分配一个新的内存堆空间。但是在JVM实例内部,只存在一个内存堆实例,所有的依赖该JVM的Java应用程序都需要共享该堆实例,而Java应用程序本身在运行的时候它自己包含了一个由JVM虚拟机实例分配的自己的堆空间,而在应用程序启动的时候,任何一个Java应用程序都会得到JVM分配的堆空间,而且针对每一个Java应用程序,这些运行Java应用程序的堆空间都是相互独立的。这里所提及到的共享堆实例是指JVM在初始化运行的时候整体堆空间只有一个,这个是Java语言平台直接从操作系统上能够拿到的整体堆空间,所以的依赖该JVM的程序都可以得到这些内存空间,但是针对每一个独立的Java应用程序而言,这些堆空间是相互独立的,每一个Java应用程序在运行最初都是依靠JVM来进行堆空间的分配的。即使是两个相同的Java应用程序,一旦在运行的时候处于不同的操作系统进程(一般为java.exe)中,它们各自分配的堆空间都是独立的,不能相互访问,只是两个Java应用进程初始化拿到的堆空间来自JVM的分配,而JVM是从最初的内存堆实例里面分配出来的。在同一个Java应用程序里面如果出现了不同的线程,则是可以共享每一个Java应用程序拿到的内存堆空间的,这也是为什么在开发多线程程序的时候,针对同一个Java应用程序必须考虑线程安全问题,因为在一个Java进程里面所有的线程是可以共享这个进程拿到的堆空间的数据的。但是Java内存有一个特性,就是JVM拥有针对新的对象分配内存的指令,但是它却不包含释放该内存空间指令,当然开发过程可以在Java源代码中显示释放内存或者说在JVM字节码中进行显示的内存释放,但是JVM仅仅只是检测堆空间中是否有引用不可达(不可以引用)的对象,然后将接下来的操作交给垃圾回收器来处理。
  对象表示:
  JVM规范里面并没有提及到Java对象如何在堆空间中表示和描述,对象表示可以理解为设计JVM的工程师在最初考虑到对象调用以及垃圾回收器针对对象的判断而独立的一种Java对象在内存中的存储结构,该结构是由设计最初考虑的。针对一个创建的类实例而言,它内部定义的实例变量以及它的超类以及一些相关的核心数据,是必须通过一定的途径进行该对象内部存储以及表示的。当开发过程给定了一个对象引用的时候,JVM必须能够通过这个引用快速从对象堆空间中去拿到该对象能够访问的数据内容。也就是说,堆空间内对象的存储结构必须为外围对象引用提供一种可以访问该对象以及控制该对象的接口使得引用能够顺利地调用该对象以及相关操作。因此,针对堆空间的对象,分配的内存中往往也包含了一些指向方法区的指针,因为从整体存储结构上讲,方法区似乎存储了很多原子级别的内容,包括方法区内最原始最单一的一些变量:比如类字段、字段数据、类型数据等等。而JVM本身针对堆空间的管理存在两种设计结构:
  1】设计一:
  堆空间的设计可以划分为两个部分:一个处理池和一个对象池,一个对象的引用可以拿到处理池的一个本地指针,而处理池主要分为两个部分:一个指向对象池里面的指针以及一个指向方法区的指针。这种结构的优势在于JVM在处理对象的时候,更加能够方便地组合堆碎片以使得所有的数据被更加方便地进行调用。当JVM需要将一个对象移动到对象池的时候,它仅仅需要更新该对象的指针到一个新的对象池的内存地址中就可以完成了,然后在处理池中针对该对象的内部结构进行相对应的处理工作。不过这样的方法也会出现一个缺点就是在处理一个对象的时候针对对象的访问需要提供两个不同的指针,这一点可能不好理解,其实可以这样讲,真正在对象处理过程存在一个根据时间戳有区别的对象状态,而对象在移动、更新以及创建的整个过程中,它的处理池里面总是包含了两个指针,一个指针是指向对象内容本身,一个指针是指向了方法区,因为一个完整的对外的对象是依靠这两部分被引用指针引用到的,而我们开发过程是不能够操作处理池的两个指针的,只有引用指针我们可以通过外围编程拿到。如果Java是按照这种设计进行对象存储,这里的引用指针就是平时提及到的“Java的引用”,只是JVM在引用指针还做了一定的封装,这种封装的规则是JVM本身设计的时候做的,它就通过这种结构在外围进行一次封装,比如Java引用不具备直接操作内存地址的能力就是该封装的一种限制规则。这种设计的结构图如下:

  2】设计二:
  另外一种堆空间设计就是使用对象引用拿到的本地指针,将该指针直接指向绑定好的对象的实例数据,这些数据里面仅仅包含了一个指向方法区原子级别的数据去拿到该实例相关数据,这种情况下只需要引用一个指针来访问对象实例数据,但是这样的情况使得对象的移动以及对象的数据更新变得更加复杂。当JVM需要移动这些数据以及进行堆内存碎片的整理的时候,就必须直接更新该对象所有运行时的数据区,这种情况可以用下图进行表示:
  JVM需要从一个对象引用来获得该引用能够引用的对象数据存在多个原因,当一个程序试图将一个对象的引用转换成为另外一个类型的时候,JVM就会检查两个引用指向的对象是否存在父子类关系,并且检查两个引用引用到的对象是否能够进行类型转换,而且所有这种类型的转换必须执行同样的一个操作:instanceof操作,在上边两种情况下,JVM都必须要去分析引用指向的对象内部的数据。当一个程序调用了一个实例方法的时候,JVM就必须进行动态绑定操作,它必须选择调用方法的引用类型,是一个基于类的方法调用还是一个基于对象的方法调用,要做到这一点,它又要获取该对象的唯一引用才可以。不管对象的实现是使用什么方式来进行对象描述,都是在针对内存中关于该对象的方法表进行操作,因为使用这样的方式加快了实例针对方法的调用,而且在JVM内部实现的时候这样的机制使得其运行表现比较良好,所以方法表的设计在JVM整体结构中发挥了极其重要的作用。关于方法表的存在与否,在JVM规范里面没有严格说明,也有可能真正在实现过程只是一个抽象概念物理层它根本不存在,针对放发表实现对于一个创建的实例而言,它本身具有不太高的内存需要求,如果该实现里面使用了方法表,则对象的方法表应该是可以很快被外围引用访问到的。
  有一种办法就是通过对象引用连接到方法表的时候,如下图:
  该图表明,在每个指针指向一个对象的时候,实际上是使用的一个特殊的数据结构,这些特殊的结构包括几个部分:
  • 一个指向该对象类所有数据的指针
  • 该对象的方法表
  实际上从图中可以看出,方法表就是一个指针数组,它的每一个元素包含了一个指针,针对每个对象的方法都可以直接通过该指针在方法区中找到匹配的数据进行相关调用,而这些方法表需要包括的内容如下:
  • 方法内存堆栈段空间中操作栈的大小以及局部变量
  • 方法字节码
  • 一个方法的异常表
  这些信息使得JVM足够针对该方法进行调用,在调用过程,这种结构也能够方便子类对象的方法直接通过指针引用到父类的一些方法定义,也就是说指针在内存空间之内通过JVM本身的调用使得父类的一些方法表也可以同样的方式被调用,当然这种调用过程避免不了两个对象之间的类型检查,但是这样的方式就使得继承的实现变得更加简单,而且方法表提供的这些数据足够引用对对象进行带有任何OO特征的对象操作。
  另外一种数据在上边的途中没有显示出来,也是从逻辑上讲内存堆中的对象的真实数据结构——对象的。这一点可能需要关联到JMM模型中讲的进行理解。JVM中的每一个对象都是和一个锁(互斥)相关联的,这种结构使得该对象可以很容易支持多线程访问,而且该对象的对象锁一次只能被一个线程访问。当一个线程在运行的时候具有某个对象的锁的时候,仅仅只有这个线程可以访问该对象的实例变量,其他线程如果需要访问该实例的实例变量就必须等待这个线程将它占有的对象锁释放过后才能够正常访问,如果一个线程请求了一个被其他线程占有的对象锁,这个请求线程也必须等到该锁被释放过后才能够拿到这个对象的对象锁。一旦这个线程拥有了一个对象锁过后,它自己可以多次向同一个锁发送对象的锁请求,但是如果它要使得被该线程锁住的对象可以被其他锁访问到的话就需要同样的释放锁的次数,比如线程A请求了对象B的对象锁三次,那么A将会一直占有B对象的对象锁,直到它将该对象锁释放了三次。
  很多对象也可能在整个生命周期都没有被对象锁锁住过,在这样的情况下对象锁相关的数据是不需要对象内部实现的,除非有线程向该对象请求了对象锁,否则这个对象就没有该对象锁的存储结构。所以上边的实现图可以知道,很多实现不包括指向对象锁的“锁数据”,锁数据的实现必须要等待某个线程向该对象发送了对象锁请求过后,而且是在第一次锁请求过后才会被实现。这个结构中,JVM却能够间接地通过一些办法针对对象的锁进行管理,比如把对象锁放在基于对象地址的搜索树上边。实现了锁结构的对象中,每一个Java对象逻辑上都在内存中成为了一个等待集,这样就使得所有的线程在锁结构里面针对对象内部数据可以独立操作,等待集就使得每个线程能够独立于其他线程去完成一个共同的设计目标以及程序执行的最终结果,这样就使得多线程的线程独享数据以及线程共享数据机制很容易实现。
  不仅仅如此,针对内存堆对象还必须存在一个对象的镜像,该镜像的主要目的是提供给垃圾回收器进行监控操作,垃圾回收器是通过对象的状态来判断该对象是否被应用,同样它需要针对堆内的对象进行监控。而当监控过程垃圾回收器收到对象回收的事件触发的时候,虽然使用了不同的垃圾回收算法,不论使用什么算法都需要通过独有的机制来判断对象目前处于哪种状态,然后根据对象状态进行操作。开发过程程序员往往不会去仔细分析当一个对象引用设置成为null了过后虚拟机内部的操作,但实际上Java里面的引用往往不像我们想像中那么简单,Java引用中的虚引用、弱引用就是使得Java引用在显示提交可回收状态的情况下对内存堆中的对象进行的反向监控,这些引用可以监视到垃圾回收器回收该对象的过程。垃圾回收器本身的实现也是需要内存堆中的对象能够提供相对应的数据的。其实这个位置到底JVM里面是否使用了完整的Java对象的镜像还是使用的一个镜像索引我没有去仔细分析过,总之是在堆结构里面存在着堆内对象的一个类似拷贝的镜像机制,使得垃圾回收器能够顺利回收不再被引用的对象。
  4)内存栈和内存堆的实现原理探测【该部分为不确定概念】:
  实际上不论是内存栈结构、方法区还是内存堆结构,归根到底使用的是操作系统的内存,操作系统的内存结构可以理解为内存块,常用的抽象方式就是一个内存堆栈,而JVM在OS上边安装了过后,就在启动Java程序的时候按照配置文件里面的内容向操作系统申请内存空间,该内存空间会按照JVM内部的方法提供相应的结构调整。
  内存栈应该是很容易理解的结构实现,一般情况下,内存栈是保持连续的,但是不绝对,内存栈申请到的地址实际上很多情况下都是连续的,而每个地址的最小单位是按照计算机位来算的,该计算机位里面只有两种状态1和0,而内存栈的使用过程就是典型的类似C++里面的普通指针结构的使用过程,直接针对指针进行++或者--操作就修改了该指针针对内存的偏移量,而这些偏移量就使得该指针可以调用不同的内存栈中的数据。至于针对内存栈发送的指令就是常见的计算机指令,而这些指令就使得该指针针对内存栈的栈帧进行指令发送,比如发送操作指令、变量读取等等,直接就使得内存栈的调用变得更加简单,而且栈帧在接受了该数据过后就知道到底针对栈帧内部的哪一个部分进行调用,是操作帧、数据帧还是局部变量。
  内存堆实际上在操作系统里面使用了双向链表的数据结构,双向链表的结构使得即使内存堆不具有连续性,每一个堆空间里面的链表也可以进入下一个堆空间,而操作系统本身在整理内存堆的时候会做一些简单的操作,然后通过每一个内存堆的双向链表就使得内存堆更加方便。而且堆空间不需要有序,甚至说有序不影响堆空间的存储结构,因为它归根到底是在内存块上边进行实现的,内存块本身是一个堆栈结构,只是该内存堆栈里面的块如何分配不由JVM决定,是由操作系统已经最开始分配好了,也就是最小存储单位。然后JVM拿到从操作系统申请的堆空间过后,先进行初始化操作,然后就可以直接使用了。
  常见的对程序有影响的内存问题主要是两种:溢出和内存泄漏,上边已经讲过了内存泄漏,其实从内存的结构分析,泄漏这种情况很难甚至说不可能发生在栈空间里面,其主要原因是栈空间本身很难出现悬停的内存,因为栈空间的存储结构有可能是内存的一个地址数组,所以在访问栈空间的时候使用的都是索引或者下标或者就是最原始的出栈和入栈的操作,这些操作使得栈里面很难出现像堆空间一样的内存悬停(也就是引用悬挂问题。堆空间悬停的内存是因为栈中存放的引用的变化,其实引用可以理解为从栈到堆的一个指针,当该指针发生变化的时候,堆内存碎片就有可能产生,而这种情况下在原始语言里面就经常发生内存泄漏的情况,因为这些悬停的堆空间在系统里面是不能够被任何本地指针引用到,就使得这些对象在未被回收的时候脱离了可操作区域并且占用了系统资源。
  栈溢出问题一直都是计算机领域里面的一个安全性问题,这里不做深入讨论,说多了就偏离主题了,而内存泄漏是程序员最容易理解的内存问题,还有一个问题来自于我一个黑客朋友就是:堆溢出现象,这种现象可能更加复杂。
  其实Java里面的内存结构,最初看来就是堆和栈的结合,实际上可以这样理解,实际上对象的实际内容才存在对象池里面,而有关对象的其他东西有可能会存储于方法区,而平时使用的时候的引用是存在内存栈上的,这样就更加容易理解它内部的结构,不仅仅如此,有时候还需要考虑到Java里面的一些字段和属性到底是对象域的还是类域的,这个也是一个比较复杂的问题。
  二者的区别简单总结一下:
  • 管理方式:JVM自己可以针对内存栈进行管理操作,而且该内存空间的释放是编译器就可以操作的内容,而堆空间在Java中JVM本身执行引擎不会对其进行释放操作,而是让垃圾回收器进行自动回收
  • 空间大小:一般情况下栈空间相对于堆空间而言比较小,这是由栈空间里面存储的数据以及本身需要的数据特性决定的,而堆空间在JVM堆实例进行分配的时候一般大小都比较大,因为堆空间在一个Java程序中需要存储太多的Java对象数据
  • 碎片相关:针对堆空间而言,即使垃圾回收器能够进行自动堆内存回收,但是堆空间的活动量相对栈空间而言比较大,很有可能存在长期的堆空间分配和释放操作,而且垃圾回收器不是实时的,它有可能使得堆空间的内存碎片主键累积起来。针对栈空间而言,因为它本身就是一个堆栈的数据结构,它的操作都是一一对应的,而且每一个最小单位的结构栈帧和堆空间内复杂的内存结构不一样,所以它一般在使用过程很少出现内存碎片。
  • 分配方式:一般情况下,栈空间有两种分配方式:静态分配和动态分配,静态分配是本身由编译器分配好了,而动态分配可能根据情况有所不同,而堆空间却是完全的动态分配的,是一个运行时级别的内存分配。而栈空间分配的内存不需要我们考虑释放问题,而堆空间即使在有垃圾回收器的前提下还是要考虑其释放问题。
  • 效率:因为内存块本身的排列就是一个典型的堆栈结构,所以栈空间的效率自然比起堆空间要高很多,而且计算机底层内存空间本身就使用了最基础的堆栈结构使得栈空间和底层结构更加符合,它的操作也变得简单就是最简单的两个指令:入栈和出栈;栈空间针对堆空间而言的弱点是灵活程度不够,特别是在动态管理的时候。而堆空间最大的优势在于动态分配,因为它在计算机底层实现可能是一个双向链表结构,所以它在管理的时候操作比栈空间复杂很多,自然它的灵活度就高了,但是这样的设计也使得堆空间的效率不如栈空间,而且低很多。

分享到:
评论
2 楼 liuxuejin 2011-04-07  
原来是书的!不过也谢谢了饿!
1 楼 liuxuejin 2011-04-07  
哇!沙发!好文章!我都仔细阅读了三遍了,中午还想继续研究一把

相关推荐

Global site tag (gtag.js) - Google Analytics