1. Java内存区域划分

在了解C++内存区域划分前,我们先了解下Java内存区域划分。

作为Android开发,我们根据《Java虚拟机规范》知道Java虚拟机在执行java程序时,会将自己管理的内存划分为以下五个区域:

  • 方法区
  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

每个区域都有自己的用途,并且创建时间和销毁时间也不一样。

1.1 方法区

方法区是各个线程共享的内存区域,此区域是用来存储类的信息(类的名称、字段信息、方法信息)、静态变量、常量以及编译器编译后的代码。JVM规范中并不区分方法区和堆,只把方法区描述为堆的逻辑部分,但是它却有一个别名叫做非堆(Non-Heap),目的就是与Java堆区分开。

根据垃圾回收机制中分代回收的思想,如果在HotSpot虚拟机上开发,可以把方法区称为“永久代”(只是可以这么理解,但实质是不一样的),垃圾回收机制在Java堆中划分一个部分称为永久代,用此区域来实现方法区,这样HotSpot的垃圾收集器就可以像管理Java堆一样管理这部分内存,而不必为方法区开发专门的内存管理器。

1.1.1 运行时常量池

在方法区中有一个非常重要的部分就是运行时常量池,常量池指的是在编译期被确定,并被保存在已编译的.class文件中的一些数据。除了包含代码中所定义的各种基本类型(如:int、long等)和对象型(如String及数组)的常量值(final)还包含一些以文本形式出现的符号引用(#类和接口的全限定名#字段的名称的描述符#方法和名称的描述符).虚拟机必须为每个被装载的类型维护一个常量池。常量池就是该类型所用到常量的一个有序集合,包括直接常量(string,integer和floating常量)和对其他类型,字段和方法的符号引用。

1.1.2 方法区和持久代的关系

“持久代”仅仅是HotSpot存在的一个概念,并且将其置于方法区,JRocket与IBM的VM都不存在这个“持久代”,最新的HotSpot也计划将其移除。在已经发布的Oracle JDK7 RC(JDK7 build 147)里,HotSpot VM仍然有PermGen,但许多原本存储在PermGen里的东西已经挪到了别的地方。 方法区物理上存在于堆里,而且是在堆的持久代里面;但在逻辑上,方法区和堆是独立的。

1.2 Java堆

Java堆是java虚拟机所管理的内存中最大的一块,是被所有线程都共享的内存区域。存在的唯一目的就是存放对象实例,几乎所有的对象实例都在这里进行分配内存。不过目前随着技术的不断发展,也并不是所有的对象实例都在堆中分配内存,可能也存在栈上分配。由于所占空间大,又存放各种实例对象,因此java虚拟机的垃圾回收机制主要管理的就是此区域。

JVM规范中规定堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。并且可以通过-Xmx和-Xms来扩展堆的内存大小,如果在堆中没有足够的内存为实例分配,并且堆也无法在扩展时,就会报OutOfMemoryError异常。

1.2.1 垃圾回收

 title=

堆内存由年轻代和老年代组成,其中年轻代又分为一个Eden区和两个Survivor区(使用复制收集算法);所有新创建的Object 一般都会存储在新生代中,如果新生代数据在一次或多次GC后存活下来,那么将被转移到Old Generation中。

新建的对象也有可能在老年代上直接分配内存,这主要有两种情况:一种为大对象,可以通过启动参数设置-XX:PretenureSizeThreshold=1024,表示超过多大时就不在年轻代分配,而是直接在老年代分配,此参数在年轻代采用Parallel Scavenge GC时无效,因为其会根据运行情况自己决定什么对象直接在老年代上分配内存;另一种为大的数组对象,且数组对象中无引用外部对象。

当老年代满了就需要对老年代进行回收,老年代的垃圾回收称为Full GC。

对象访问会涉及到Java栈、Java堆、方法区这三个内存区域,Object obj = new Object(); Object obj 这部分将会反映到Java栈的本地变量表中,作为一个reference类型数据出现。而“new Object()”这部分将会反映到Java堆中,形成一块存储Object类型所有实例数据值的结构化内存,根据具体类型以及虚拟机实现的对象内存布局的不同,这块内存的长度是不固定。另外,在java堆中还必须包括能查找到此对象类型数据(如对象类型、父类、实现的接口、方法等)的地址信息,这些数据类型存储在方法区中。根据reference去访问实例对象有两种访问方式:句柄访问方式、指针访问方式。
 title=

 title=

  • 内存泄露:指程序中一些对象不会被GC所回收,它始终占用内存,即被分配的对象引用链可达但已无用。(可用内存减少)。内存溢出:程序运行过程中无法申请到足够的内存而导致的一种错误。内存溢出通常发生于OLD段或Perm段垃圾回收后,仍然无内存空间容纳新的Java对象的情况。内存泄露是内存溢出的一种诱因,不是唯一因素。
  • -Xms参数和-Xmx参数可以控制堆内存,默认空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制;空余堆内存大于70%时,JVM会减少堆直到 -Xms的最小限制。因此服务器一般设置-Xms、-Xmx相等以避免在每次GC 后调整堆的大小。
  • JVM使用-XX:PermSize设置非堆内存初始值,默认是物理内存的1/64;由XX:MaxPermSize设置最大非堆内存的大小,默认是物理内存的1/4。

在GC算法进行垃圾回收时首先要进行对象存活性判断,一般有两种方法:引用计数、可达性分析(在java语言中可作为GC Roots进行可达性分析的对象包括:虚拟机栈中引用的对象,方法区中类静态属性实体引用的对象,方法区中常量引用的对象,本地方法栈中JNI引用的对象。)。垃圾收集算法分:标记清除算法、复制算法、标记整理算法;JVM的收集器采用分代收集,分代收集是在不同的区使用上面三种算法,以及是否可并行收集。

1.3 程序计数器

程序计数器实际上是一块较小的内存空间,可以看做当前线程所执行字节码的行号指示器,功能类似于计算机组成原理中的PC寄存器,用于存放下一条指令所在单元的地址。

执行一条指令时,首先需要根据PC中存放的指令地址,将指令由内存取到指令寄存器中,此过程称为“取指令”。与此同时,PC中的地址或自动加1或由转移指针给出下一条指令的地址。此后经过分析指令,执行指令。完成第一条指令的执行,而后根据PC取出第二条指令的地址,如此循环,执行每一条指令。虽然JVM的程序计数器跟PC有所区别,但是在概念上是等同的,JVM中的PC存放的是程序正在执行的字节码的行号,字节码解释器的工作就是通过改变程序计数器的值来选择下一条需要执行的字节码指令。

字节码解释器在工作中时下一步该干啥、到哪了,就是通过它来确定的。

我们都知道在多线程的情况下,CPU在执行线程时是通过轮流切换线程实现的,也就是说一个CPU处理器(假设是单核)都只会执行一条线程中的指令,因此为了线程切换后能恢复到正确的执行位置,每个线程都要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。很明显,程序计数器就是线程私有的。

  • 如果线程正在执行的是一个java方法,程序计数器记录的是正在执行的虚拟机字节码指令地址;
  • 如果执行的Native方法,程序计数器记录的值为空(Undefined)

由于程序计数器是固定宽度的存储空间,所以此内存区域是java中唯一一个在java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

1.3 Java虚拟机栈

我们通常考虑Java内存的两块区域:堆和栈,Java虚拟机栈就是栈这一部分,或者说是虚拟机栈中局部变量表部分。

虚拟机栈中存放每个方法执行时创建的栈帧,对于执行引擎来讲,活动线程中,只有栈顶的栈帧是有效的,称为当前栈帧,这个栈帧所关联的方法称为当前方法,执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。

跟程序计数器一样,虚拟机栈也是线程私有的,它的生命周期跟线程相同。每个方法在执行的同时都会创建一个栈帧(Stack Frame),每个栈帧对应一个被调用的方法。栈帧用于存放局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息。在编译程序代码时,栈帧中需要多大的局部变量表、多深的操作数栈都已经完全确定了,并且写入了方法表的 Code 属性之中。因此,一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。每一个方法从开始执行到结束就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

  • 局部变量表:他就是用来存储方法中的局部变量(包括在方法中生命的非静态变量以及函数形参),对于基本数据类型,直接存值,对于引用类型的变量,存储指向该对象的引用。由于它只存放基本数据类型的变量、引用类型的地址和返回值的地址,这些类型所需空间大小已知且固定,所以当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全可以确定的,在方法运行期间也不会改变局部变量表的大小。
  • 操作数栈:程序中的计算是通过操作数栈来完成的,操作数栈的最大深度也是在编译的时候就确定了。Java 虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。因此我们也称 Java 虚拟机是基于栈的,这点不同于 Android 虚拟机,Android 虚拟机是基于寄存器的。基于栈的优点是可移植性强,缺点是速度相对较慢。
  • 动态链接(指向运行常量池的引用):
  • 个栈帧都包含一个指向运行时常量池的引用,持有这个引用是为了支持方法调用过程中的动态连接。Class 文件的常量池中存在有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用,一部分会在类加载阶段或第一次使用的时候转化为直接引用(如 final、static 域等),称为静态解析,另一部分将在每一次的运行期间转化为直接引用,这部分称为动态连接。
  • 方法返回地址:
  • 方法正常退出时,调用者的 PC 计数器的值就可以作为返回地址,栈帧中很可能保存了这个计数器值,而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,如果有返回值,则把它压入调用者栈帧的操作数栈中,调整 PC 计数器的值以指向方法调用指令后面的一条指令。
  • -Xss128k:设置每个线程的栈大小,jdk1.5以后每个线程的栈大小为1M,减小这个值能生成更多的线程,但同时可能会带来OutOfMemoryError。

在Java虚拟机规范中针对这个区域规定了两种异常:

  1. 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;
  2. 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

1.5 本地方法栈

本地方法栈与虚拟机栈的功能非常的相似,区别不过是虚拟机栈为虚拟机执行java方法服务,而本地方法栈为虚拟机执行Native方法服务。有的虚拟机并不会区分本地方法栈和虚拟机栈,比如Sun HotSpot虚拟机直接将两个合二为一。

最后可以用下面的图来描述整个Java内存区域划分:

 title=

2. C++内存区域划分

Java程序的执行依赖于虚拟机,内存区域划分具体实现依赖于虚拟机实现,而C++则直接与操作系统交互,内存结构直接依赖于操作系统实现。

一块程序被加载到内存中,首先,这块内存就存在两种属性:动态分配内存和静态分配内存。

  • 静态分配内存:程序编译和链接时就确定好的内存。
  • 动态分配内存:程序加载、调入、执行时分配和回收的内存。

一些基本概念:

  1. 两大分区:代码区和数据区
  2. 四大分区:代码区,全局区(全局/静态存储区),栈区,堆区
  3. C语言分区:堆,栈,静态全局变量区,常量区
  4. C++语言分区:堆、栈、自由存储区、全局/静态存储区、常量存储区
  5. C/C++内存模型根据生命周期的不同有三大区:即自由存储区(C++),动态区、静态区
  6. 代码虽然占内存,但不属于c/c++内存模型的一部分

2.1 各分区实际内存结构

 title=

  • text段: 也称Code Segment,用于存放可执行程序的机器码, 编译时确定, 只读。更进一步讲是存放处理器的机器指令,当各个源文件单独编译之后生成目标文件,经连接器链接各个目标文件并解决各个源文件之间函数的引用,与此同时,还得将所有目标文件中的.text段合在一起,但不是简单的将它们“堆”在一起就完事,还需要处理各个段之间的函数引用问题。
  • data段(Data Segment) :用于存放在编译阶段(而非运行时)就能确定的数据,可读可写。也是通常所说的静态存储区,赋了初值的全局变量、常量和静态变量都存放在这个域。 (已经初始化的数据)
  • BSS(Block started by symbol)段:不在可执行文件中,由系统初始化。(未初始化的数据),BSS段的变量的值默认为0。
  • heap:由程序员分配和释放;若程序员不释放,程序结束时操作系统会进行回收。(malloc,free,new,delete)
  • stack:存放函数的参数值和局部变量,由编译器自动分配和释放
  • env:环境变量

可执行程序的文件包含BSS,Data Segment和Code Segment。

2.2 各分区介绍

2.2.1 堆 heap

由程序员释放:指的是程序员使用malloc/free,或者new/delete(自由存储区,自由存储区可以理解是堆区的一个子集,不完全等价)申请或者销毁的内存所在的区域。
如果程序员没有释放,在程序运行过程中,可能出现内存泄漏的状况(堆上无用的内存没有及时的销毁free/malloc,程序无法在堆上找到空闲的内存,目前的操作系统通常有虚拟内存技术,每个进程都有可能将整个内存空间完全占满,这样内存泄漏是非常危险的,会导致其他进程的内存页被换下,会导致大量的缺页中断,最后死机蓝屏)

看一个堆内空间重复利用的例子:

#include<iostream>
void test1()
{
  int *p=new int(1);
    printf("test1 p:%p\n",p);
    delete p;
}
int main()
{
    int *p=new int(1);
    printf("main p :%p\n",p);
    test1();
    int *p2=new int(1);
    printf("main p2:%p\n",p2);
    delete p;
    delete p2;
    return 0;
} 

结果:

main p :0x60000000c000
test1 p:0x600000008010
main p2:0x600000008010

test1函数内申请完堆内存后释放,在main函数中申请相同大小的空间,刚释放的空间又被main函数中申请到。

2.2.2 栈 stack

由编译器自动分配释放,存放函数的参数值,局部变量,(形参)等。先看一个示例:

#include <iostream>

int main(int argc, char *argv[]){
    int a = 1;
    int b = 2;
    

    int *p1 = new int(1);
    int *p2 = new int(2);
    int *p3 = new int(3);
    std::cout << sizeof(int) << std::endl;
    std::cout << &a << " " << &b <<std::endl;
    std::cout << p1 << " " << p2  << " " << p3 << std::endl;
    std::cout << &p1 << " " << &p2 << " " << &p3 << std::endl;
}

输出:

0x7ff7bfeff43c 0x7ff7bfeff438
0x600000008010 0x600000008020 0x600000008030
0x7ff7bfeff430 0x7ff7bfeff428 0x7ff7bfeff420

简单分析:

  1. 变量a先定义,但是地址大于b,说明栈内存地址是从高到底分配。
  2. int型变量占4个字节,int型指针占8个字节;
  3. 堆内存地址从低到高分配;
  4. int变量b与指针p1之间内存间隔大,多了4个字节。

如果栈中有函数调用呢?我们再看一个例子:

#include<iostream>
void test(int x,int y,int z)
{
    int a=1;
    int b=2;
    int c=3;
    printf("test(x):%p\n",&x);
    printf("test(y):%p\n",&y);
    printf("test(z):%p\n",&z);
    printf("test(a):%p\n",&a);
    printf("test(b):%p\n",&b);
    printf("test(c):%p\n",&c);
}
int main()
{
    int a=1;
    int b=2;
    int c=3;
    int d=4;
    int e=5;
    printf("main(a):%p\n",&a);
    printf("main(b):%p\n",&b);
    printf("main(c):%p\n",&c);
    test(a,b,c);
    printf("main(d):%p\n",&d);
    printf("main(e):%p\n",&e);
    return 0;
} 

输出结果:

main(a):0x7ff7bfeff448
main(b):0x7ff7bfeff444
main(c):0x7ff7bfeff440
test(x):0x7ff7bfeff41c
test(y):0x7ff7bfeff418
test(z):0x7ff7bfeff414
test(a):0x7ff7bfeff410
test(b):0x7ff7bfeff40c
test(c):0x7ff7bfeff408
main(d):0x7ff7bfeff43c
main(e):0x7ff7bfeff438

结果分析:

  1. 对于main(&a),main(&b),main(&c):我们初始化abc的顺序是abc,但是在内存中编址确实从大到小。这就说明了栈的内存分布是从高位向低位进行分配的,跟上面结论一致。
  2. 观察main(&c)和test(&x):我们发现main(&x)和test(&z)之间相隔比较大。
  3. 观察main(&c)和test(&z);我们发现相隔比main(&c)和test(&x)大一些。根据栈的内存分布是从高位向低位进行分配我们推断我们编译器的函数栈的入参方式是从左到右,并且在入参之前还有其他的参数.
  4. 观察main(&d)和main (&c):正好又相隔四个字节,说明调用test时入栈的参数全部被自动清除了

函数入栈方式
这里我们需要了解几个概念:

  • 栈帧:每个函数有者自己的栈帧,栈帧中维持着所需要的各种信息(参数,返回地址,参数个数,其他)
  • 寄存器esp:保存当前栈帧的栈顶
  • 寄存器ebp:报错当前栈帧的栈底

寄存器不在C/C++内存模型的讨论范围.

函数栈入参结构:
函数局部变量->(ebp)最左参数->中间参数->最右参数->返回地址->运行时参数(esp)

ebp 可以定位函数形参,定位返回地址,esp可以清空运行时内存。

从右到左入参场景:
根据栈的特点,第一个形参将在固定的ebp+4的位置,很方便获取方便使用
为什么方便获取第一个参数就是好呢?这就要说到C中的可变参数函数:在使用可变参数函数的时候必须有第一个参数,根据第一个参数可以定位可变参数的长度,设想一下如果不是从右向左入参,那么可变参数无法获取,可变参数的长度将无法获取(读取文件可以解决),所以说向右入参可以兼容C语言中的一些设定

2.2.3 全局/静态存储区

全局变量和静态变量被分配到同一块内存中。

在C语言中分为两个大类三个小类:

  • 两个大类(.bss .data);
  • 三个小类(全局未初始化(.bss的高地址),静态未初始化(.bss的低地址),已初始化(.data))。

在 C++ 中的.bss段,他们共同占用同一块内存区。

示例代码:

#include<stdio.h>
int w;
int x=1;
static int y;
static int z=2;

int main()
{
    printf("w=%p\n",&w);
    printf("x=%p\n",&x);
    printf("y=%p\n",&y);
    printf("z=%p\n",&z);
    return 0;
} 

C输出结果:

w=0x100008018
x=0x100008010
y=0x10000801c
z=0x100008014

分析:

  • w和x同为全局变量,但是w没有初始化,x初始化,它分布在不同的两个地方(.bss,.data)
  • w和y同为未初始化变量,但是w不是静态,y是静态, 他们分布在.bss的不同地方
  • x和z同为初始化变量,z是静态,它们分布在.data的同一个地方
  • y和z同为静态变量,y没有初始化,z初始化,它们分布在两个不同地方(.bss .data)

所以:全局静态存储器可以分为两个大类(.bss .data),三个小类(全局未初始化,静态未初始化,已初始化(.data))

C++输出结果:

w=0x1000080e0
x=0x1000080d8
y=0x1000080e4
z=0x1000080dc

结论:这里面看到mac编译器C与C++的.bss段不在区分全局和静态只有两个大类了。

2.2.4 常量区

存放常量的区间,如字符串常量等,注意在常量区存放的数据一旦经初始化后就不能被修改。 程序结束后由系统释放。

测试代码:

#include<iostream>
const char* p1 ="abcd";

int main()
{
    const char* p2 ="abcd";
    char * p3 = "abcd";
    char * p4 = p3;
    char s[]="abcd";
    char s2[]="abcd";
    s[1]='1';
    //*p1='1';,这一步会报错 
    *s='1';
    printf("%p\n",p1);
    printf("%p\n",p2);
    printf("%p\n",p3);
    printf("%p\n",p4);
    printf("%p\n",s);
    printf("%p\n",s2);
    return 0;
} 

结果:

0x100003fa2
0x100003fa2
0x100003fa2
0x100003fa2
0x7ff7bfeff42b
0x7ff7bfeff426

p1,p2,p3,p4都是常量,s,s2都是储存在栈区的变量(s到s2的地址在减小)

2.3 堆和栈的区别:

  1. 管理方式不同:栈,由编译器自动管理,无需程序员手工控制;堆:产生和释放由程序员控制。
  2. 空间大小不同:栈的空间有限;堆内存可以达到4G,。
  3. 能否产生碎片不同:栈不会产生碎片,因为栈是种先进后出的队列。堆则容易产生碎片,多次的new/delete(delete顺序没有考究,没有用的数据没有马上delete或者太长时间没有用)
    会造成内存的不连续,从而造成大量的碎片。
  4. 生长方向不同:堆的生长方式是向上的,栈是向下的。(堆保存在低地址,栈保存在高地址)
  5. 分配方式不同:堆是动态分配的(malloc,new)。栈可以是静态分配和动态分配两种(栈的动态分配是由编译器进行释放alloca函数),但是栈的动态分配由编译器释放。
  6. 分配效率不同:栈是机器系统提供的数据结构,计算机底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令。堆则是由C/C++函数库提供,库函数会按照一定的算法在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是由于内存碎片太多),就有可能调用系统功能去增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。显然,堆的效率比栈要低得多。 (主要影响原因,内存碎片,底层支持程度)

2.4 堆和自由存储区的区别:

我们常说,由malloc/free 申请或者销毁的内存分布在堆区,由new/delete申请的内存在自由存储区。
但是new/delete在默认情况下是使用了malloc/free 那我们能不能说自由存储区是堆区的子集呢?不能
在C++中我们可以对new进行重载(实际上new 和 delete 有一种形式不能被重载),我们可以让对象的内存空间不在堆区而在全局区或者其他。这个时候自由存储区就不是堆区的子集。

  • 自由存储区:由new/delete管理的内存区域(new和delete能管理到的内存区域都可以叫自由存储区,是一个逻辑概念)
  • 堆区:实际内存区的一个固定部分(是一个物理概念)

2.5 C++ new/delete 的工作过程

申请的是普通的内置类型的空间:

  1. 调用 C++标准库中 operator new函数,传入大小。(如果申请的是0byte则强制转化成1byte)
  2. 申请相对应的空间,如果没有足够的空间或其他问题且没有定义_new_hanlder,那么会抛出bad_alloc的异常并结束程序,如果定义了_new_hanlder回调函数,那么会一直不停的调用这个函数直到问题被解决为止
  3. 返回申请到的内存的首地址.

申请的是类空间:

  1. 如果是申请的是0byte,强制转换为1byte
  2. 申请相对应的空间,如果没有足够的空间或其他问题且没有定义_new_hanlder,那么会抛出bad_alloc的异常并结束程序
  3. _如果定义了_new_hanlder回调函数,那么会一直不停的调用这个函数直到问题被解决为止。
  4. 如果这个类没有定义任何构造函数,析构函数,且编译器没有合成,那么下面的步骤跟申请普通的内置类型是一样的。
  5. 如果有构造函数或者析构函数,那么会调用一个库函数,具体什么库函数依编译器不同而不同,这个库函数会回调类的构造函数。
  6. 如果在构造函数中发生异常,那么会释放刚刚申请的空间并返回异常
  7. 返回申请到的内存的首地址

delete 与new相反,会先调用析构函数再去释放内存(delete 实际调用 operator delete)

operator new[]的形参是 sizeof(T)*N+4就是总大小加上一个4(用来保存个数);空间中前四个字节保存填充长度。然后执行N次operator new
operator delete[] 类似;

注意:void* operator new(size_t, void*); // 不允许重新定义这个版本


轻口味
25.2k 声望4.2k 粉丝

移动端十年老人,主要做IM、音视频、AI方向,目前在做鸿蒙化适配,欢迎这些方向的同学交流:wodekouwei