JVM笔记

来源:互联网 发布:天津教研室网络平台 编辑:程序博客网 时间:2024/04/20 10:32

局部变量表(虚拟机栈中的一部分)在编译期完成分配,运行期不会再改变大小;

每个方法对应一个栈帧(存储局部变量表、操作数栈、动态链接、方法出口等),栈帧被存储到虚拟机栈中,每个线程对应一个虚拟机栈,方法结束,栈帧生命周期结束,线程结束,虚拟机栈生命周期结束;

如果线程请求的虚拟机栈深度大于虚拟机所允许的深度,throw StackOverflowerror;

如果动态扩展时请求不到足够内存,throw OutOfMemoryError;

所有的对象实例以及数组都要在堆上分配(不绝对),堆是所有线程共享的,在虚拟机启动时创建,堆是垃圾回收的主要区域,GC堆;

Java虚拟机栈、本地方法栈、堆都等,除了程序计数器,其他内存区都会throw OutOfMemoryError;

堆物理上可以不连续,逻辑上是连续的;

方法区也是各个线程共享的,其中包括运行时常量池,类的描述以及常量都存放在这个池里;

直接内存常用于I/O库的操作;直接内存操作能提高性能,因为避免了Java堆和Native堆的数据复制;

指针方式
句柄方式可以带来更大的伸缩性,对象被移动时reference的值不必被修改;

指针方式可以带来更高的访问性能,因为它节省了一次指针定位;

虚拟机用哪种方式是虚拟机自己实现的;

每一个栈帧中非配多少内存基本上是在类结构确定后就已知的,所以程序计数器、虚拟机栈、本地方法栈这些随线程而生而灭的区域的内存分配和回收都具有确定性;

方法结束,栈帧内存被回收;线程结束,栈内存被回收;

而Java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,只有运行期才能知道要创建哪些对象,这部分的内存分配和回收是动态的;

PS:堆中存放对象,方法区(永久代)存放类;

存放类和常量的方法区的回收效率是低下的

判断一个类无用的三个必要条件:

该类的所有实例都已经被回收,也就是Java堆中不存在该类的任何实例;
加载该类的ClassLoader已经被回收;
该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法;
判断对象存活的方法

1,引用计数器Reference Counting,无法判断互相引用的对象已经无效,虚拟机并不用这种方法;

2,根搜索算法GC Roots Tracing,从GC roots到每个对象不可达时,此对象失效,Java用这种算法;

四种引用reference

JDK 1.2之后,扩种的四种references:

Strong Reference:在程序代码中普遍存在的,类似Object obj = new Object()的引用,只要强引用还在,GC永远不会回收;

Soft Reference:用来描述一些还有用,但并非必需的对象,对于软引用关联着的对象,在系统将要发生内存溢出之前,将会把这些对象列进回收范围之中并进行第二次回收。如果这次回收还是没有足够的内存,才会抛出OOM。1.2之后,有专门的类SoftReference来实现软引用;

Weak Reference:同上,也是用来描述非必需对象,比软引用更弱,被弱引用关联的对象只能生存到下一次GC之前。当GC工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。1.2之后,专门的类WeakReference用来实现弱引用;

Phantom Reference:虚引用是最弱的引用,不会对对象的生存时间有影响,也无法通过虚引用来取得对象实例。虚引用的唯一目的是在这个对象被GC时收到一个系统通知。1.2之后,专门的类PhantomReference用来实现虚引用。

关于Compile Time:运行期为什么会编译?为了解决Java字节码解释执行的速度问题,虚拟机内置了运行期编译器,如果一段Java方法被多次调用,则判定为Hot方法,交给JIT编译器编译为本地代码以提高运行速度;

如果没有指定老年代的大小,则每次FULL GC都有可能扩容,反过来说,扩容就需要FUll GC一次,线程就要停顿,所以提高性能的一个方法就是把Java堆(新生区,老年代。。)的容量固定下来;并且可以-XX:+DisableExplicitGC禁止代码显示调用的System.gc();

新生区的Minor GC耗时短,但发生频率高;老年代的FullGC耗时长,但发生频率低;

除了调节堆、新生区、Eden和Survivor的比例、Old Gen、Perm Gen的大小等措施,还可以给虚拟机指定垃圾收集器来提高性能,比如用并发收集器而非串行收集器;

并发收集器的GC时间还要加上并发本身的时间才可与串行收集器的GC时间进行比较;

-XX:UserConcMarkSweepGC和-XX:UserParNewGC,指定虚拟机在新生区和老年代分别使用ParNew和CMS收集器进行GC;

虚拟机:平台无关性—>语言无关性

平台无关性:一次编写,到处运行,Write Once, Run Anywhere;字节码ByteCode代替机器码NativeCode;借助于虚拟机,将代码一一编译为与平台密切相关的二进制码不是唯一的选择了;

语言无关性:虚拟机不再关心ByteCode是由何种语言编译而来,只要符合其规范;

运行的时候NoIntitialization与ConstClass已经没有关系

编译阶段会将常量“Hello World!”存储到NoIntitialization的常量池中:

1
2
3
4
5
6
7
8
9
10
11
class ConstClass {

static {

System.out.println(“Constant init!”);

}

public static final String HELLOWORLD = “Hello World!”;

}
Java虚拟机内存模型;

volatile变量能保证可见性的意思是:当一个线程在其工作内存中修改了一个volatile变量,该新值会立即被其他线程得知;

变量值的修在线程间传递需要通过主内存完成;

变量在工作内存中被assign之后必须同步回工作内存;

硬件部署策略:64位JDK的大内存还是32位逻辑集群?

Full GC主要清除老年代,老年代没满的话就不会Full GC;Minor GC主要清理新生区;

Full GC容易引起停顿,如果回收器不是与用户线程并发执行的;

Eden+Survior=新生区,Eden是使用的内存,Survior是备用的采用复制算法做清除的内存。

堆可能会被及时回收而不发生OOM,但堆之外的一些区域也会占用内存并且不会被频繁回收,这样也会导致OOM;

Overload是静态分派,Override是动态分派

虚拟机编译器在重载overload时是通过参数的静态类型而不是实际类型来作为判断的:

下面代码中,one和two的静态类型都是Fu,实际类型只能在运行期才知道;但overload是在编译期就要确定类型的,所以就认了静态类型;

静态分派:发生在编译期,overload是典型的静态分派实例,会找“最合适的方法目标”,按照char->int->long->float->double(char不会转到byte和short,不安全)的顺序转型进行方法匹配;或者char->Character,自动装箱;还能继续转型,比如char->Character->Serializable/Comparable,因为Character实现了Serializable和Comparable,从子类转到父类是理所当然的,但如果同时有多个Character父类类型参数的方法,则编译错误,因为类型模糊,拒绝编译;接着会继续转型为Object,再继续还可以转为char …;

public class Test {

public static void main(String[] args) {

Fu one = new One();

Fu two = new Two();

Load l = new Load();

l.call(one);

l.call(two);

}

}

class Load {

public void call(Fu f) {

System.out.println(“Fu”);

}

public void call(One o) {

System.out.println(“One”);

}

public void call(Two t) {

System.out.println(“Two”);

}

}

class Fu {

}

class One extends Fu {

}

class Two extends Fu {

}

Override是动态分派,可以进行多态选择:

动态分派的过程:invokevirtual指令执行的第一步就是在运行期确定接受者的实际类型,所以调用中的invokevirual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这就是重写的本质,也是动态分派的过程;

但实际上虚拟机会做优化,因为动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此这种频繁的搜索对性能有影响;

最常用的优化手段就是在方法去中建立一个虚方法表/接口方法表,虚方法表就是提前将各个方法的实际入口地址存放好,在执行时使用虚方法表索引替代元数据查找以提高性能;

稳定优化的原则:虚方法表示每个类都有一个,如果方法在子类中没有被重写,那么子类的虚方法表里面的地址入口和父类方法的地址入口时一致的(本来是先从子类里找,找不到再去父类中找,现在直接将父类的地址复制到子类中了,直接从子类中获取);如果重写了,子类方法表中的地址会被替换为指向子类实现版本的入口地址;

所以,如果一个类没有重写Object的方法,这个很常见,那么这个类的虚方法表中的Object继承而来的方法…;

方法调用:虚方法和非虚方法

多态针对的是什么时机的多态?多态指的是运行期的多态选择;

非虚方法可以在编译期就确定其调用目标,无法多态,非虚方法包括:

静态方法-invokestatic;

私有方法、实例构造方法、父类方法-invokespecial;

final方法-invokevirtual(特例);

而虚方法和接口方法都是在运行期动态指定调用目标的:

invokevirtual-虚方法; invokeinterface-接口方法;

把用完的变量赋null的意义

1
2
3
4
5
6
7
{

    byte [] placeholder = new byte[64 *1024 * 1024];    system.gc();

}
以上代码,JVM加上-verbose.gc查看,system.gc后并没有回收掉placeholder;

初步怀疑是因为gc的时候placeholder还在作用域,就是说这个局部变量表中的slot还有用;

1
2
3
4
5
6
7
8
9
10
11
{

    {            byte [] placeholder = new byte[64 *1024 * 1024];    }        system.gc();

}
但以上代码仍然没有及时回收placeholder;

1
2
3
4
5
6
7
8
9
10
11
12
13
{

    {            byte [] placeholder = new byte[64 *1024 * 1024];    }        int a = 0;    system.gc();

}
而这段代码则及时回收了placeholder,why?

这三段代码依次是:

局部变量表中的Slot还有关于placeholder数组对象的引用;
已经没有对placeholder的引用,但是,没有新的读写操作,placeholder原来占用的Slot还没有被其他变量复用。
这段代码是编译不过的,因为局部变量只有一次赋值机会,不像全局变量;

1
2
3
4
5
6
7
{

int a;

System.out.println(a);

}
形象地理解栈帧:每一个方法从调用开始到执行完成的过程,就对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程;

栈帧中存放的局部变量表、操作数栈、动态连接、方法返回地址等在编译期就确定并写入到.class文件中了(类文件的Code属性);

Slot是局部变量表中存放变量的最小单位,一般32或64位,boolean, byte,char,int, short, float, reference, returnAddress等被一个32位的slot存放;double和long可被slot存放;但这些都不一定;

局部变量表就是存放局部变量的,可以复用;

操作数栈是执行该栈帧时动态的存取操作数的区域,比如要做加法,就存两个加数到操作数栈上;

动态连接就是指向运行时常量池(和虚拟机栈同级别的另一个内存块),连接与该栈帧相关的引用,比如当前栈帧要调用哪个方法,指向常量池中的方法引用;

方法退出就是栈帧出栈;

Java虚拟机的解释引擎成为基于栈的执行引擎的意义

这里的栈就是操作数栈,栈帧中的一个单元;

Java编译器:词法分析,语法分析,抽象语法树,遍历语法树生成字节码指令流;

Java虚拟机中的解释器:Slot和操作数栈配合,中间变量都以操作数的出栈和入栈为信息交换途径;

说Java的指令集是基于栈的指令集架构,是相对于基于寄存器的指令集而言的;前者的优点是可移植性,后者的优点是速度快;

FinalizeEscapeGC演示

当一个对象到GC roots不可达时,并不会被马上回收,还会在队列中标记;

任何一个对象的finalize只会被系统自动调用一次,所以finalize方法是对象拯救自己的最后一次机会,如果在调用finalize时对象拯救了自己,下一次回收就无法拯救了。

finalize不是对象回收方法,而是系统回收对象时给对象一次析构的机会,一次之后,直接回收;

finalize运行代价高,不确定性大,无法保证各个对象的调用顺序,所以不要用finalize做对象的收尾工作,而是用try-finally。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
public class FinalizeEscapeGC {

public static FinalizeEscapeGC SAVE_HOOK = null;

public void isAlive() {

System.out.println(“alive…”);

}

@Override

protected void finalize() throws Throwable{

super.finalize();

System.out.println(“finalize()…”);

SAVE_HOOK = this;

}

public static void main(String[] args) throws Throwable {

SAVE_HOOK = new FinalizeEscapeGC();

//Save object first time

{

SAVE_HOOK = null;

System.gc();

//the priority of finalize() is low, so pause 0.5 second to wait it

Thread.sleep(500);

if(SAVE_HOOK != null) {

SAVE_HOOK.isAlive();

} else {

System.out.println(“dead…”);

}

}

//save failure

{

SAVE_HOOK = null;

System.gc();

//the priority of finalize() is low, so pause 0.5 second to wait it

Thread.sleep(500);

if(SAVE_HOOK != null) {

SAVE_HOOK.isAlive();

} else {

System.out.println(“dead…”);

}
}
}
}
垃圾回收算法

1, 标记-清除算法,缺点是效率低,清除后内存不连续;

2, 复制算法,按照1:1预留内存空间以保证极端的100%存活的对象能够有空间复制;

3, 标记-整理算法,就是标记-整理,再清除,整理就是把标记的对象往一端移动,最后全部清理,可保证清理后内存的连续性;

4,分代收集算法,分情况使用不同算法,堆的新生代的对象被回收后会大批死去,所以可以使用复制算法,把1:1的预留方案调整为8:1等比例来节约空间,而堆中的老年代对象存活率高,适合用标记-清除/整理算法。

Java基础回顾

int: 32, short和long的整型分别是16和64;

char: 16位;

byte: 8位;

float: 32位,单精度实数; double:64位, 双精度实数;

数值会自动被提升为高级的数据类型,就是位少的被转为位多的,相反方向的转换不安全;

数组只可以:= new int[3] or ={1, 2, 3};

内存泄漏:pop方法导致对象游离

一般来说内存泄漏有两种情况。一种情况如在C/C++语言中的,在堆中的分配的内存,在没有将其释放掉的时候,就将所有能访问这块内存的方式都删掉(如指针重新赋值);另一种情况则是在内存对象明明已经不需要的时候,还仍然保留着这块内存和它的访问方式(引用)。第一种情况,在Java中已经由于垃圾回收机制的引入,得到了很好的解决。所以,Java中的内存泄漏,主要指的是第二种情况。

例子:

Vector v=new Vector(10);

for (int i=1;i<100; i++){

Object o=new Object();

v.add(o);

o=null;

}

//原文是说,o 被置为null了,但仍然被v引用所以无法释放;

//但此时v.get(i)还是可用的,从逻辑上讲o还有存在意义,也不该被释放;

//当v被null之后,o应该会被回收了吧?

看下面的描述:

对象游离: Stack的pop方法写的不好,就有可能导致内存泄漏;

用数组实现堆栈,pop之后,当前对象在堆栈范围内已经无用了,如果客户代码那也用完了这个对象,其该被回收,但是,因为Stack内部的数组还有对这个对象的引用,导致无法被GC,除非再次push,该数组位的引用值被重新指向另一个对象,原来那个对象就被GC了;

如果用API中的List实现堆栈,因为List本身的remove方法已经采取了避免对象游离的措施,所以就没这个问题;

//当v被null之后,o应该会被回收了吧?

这个问题的答案就是,o不能陪着有可能常驻内存的一个堆栈中的v一起常驻内存,这显然不合理。

JVM常用参数

【设置堆的整体参数】

-Xms: 起始堆大小,堆分配超过这个值后会先Full GC,如果还不够,才申请至-Xmx;

-Xmx: 可获取的最大的堆大小,-Xmx=-Xms可以减少Full GC的次数;

-XX:MinHeapFreeRatio: 堆空间的最小空闲比,当堆空间的空闲内存小于这个值时,JVM会扩展堆空间;

-XX:MaxHeapFreeRatio: 堆空间的最大空闲比,当堆空间的空闲内存大于这个值时,JVM会压缩堆空间;

【设置堆中的新生代参数】

-XX:NewSize: 设置新生代的初始大小;

-XX:MaxNewSize: 设置新生代的最大空间大小;

-Xmn: 同时设定-XX:NewSize和-XX:MaxNewSize,推荐;

【设置堆中的新生代和老年代的关系】

-XX:NewRatio: 老年代大小 / 新生代大小

【设置新生代中的eden和survivior的关系】

-XX:SurviorRatio: eden / survivior

【设置survivior与老年代的传送关系】

-XX:TargetSurvivorRatio: 当Survivior区的空间使用率达到这个数时,会将对象送入老年代;

【设置Perm区】

-XX:PermSize 永久区的初始值,这个区主要是常量池和类信息;

-XX:MaxPermSize

【设置JVM栈】

-Xss: 设置虚拟机栈(线程栈)的大小;

虚拟机栈空间(-Xss) * 线程数 = 内存 – 堆空间

虚拟机栈中的栈帧空间 * 函数调用深度 = 虚拟机栈空间

【设置JVM信息报告参数】

-XX:+PrintGCDetails

-XX:+PrintGC

制造Heap OOM

//-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8

1
2
3
4
5
6
7
8
9
List list = new ArrayList();

int i = 10000000;

while(i– > 0) {

list.add(new Object());

}
制造VM Stack SOF和OOM

给每个线程的栈分配的内存越大,越容易产生OOM,因为整个VM栈被分配到的总内存大小是固定的,如果给每个线程的栈的内存太大,可建立的线程数就少;所以减少线程的栈容量/栈深度可以解决OOM问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
public class VMStackOOM {

private int stackLen = -1;

public void stackLeak() {

/*if(stackLen > 1000)

return;*/

stackLen++;

stackLeak();

}

private void donotStop() {

}

public void stackLeakByThread() {

while(true) {

Thread thread = new Thread(new Runnable() {

@Override

public void run() {

donotStop();

}

});

thread.start();

}

}

public static void main(String[] args) throws Throwable {

//one thread

VMStackOOM oom = new VMStackOOM();

try {

oom.stackLeak();

} catch(Throwable e) {

System.out.println(“stack length is: ” + oom.stackLen);

throw e;

}

//multi thread

oom.stackLeakByThread();

}
}
制造runtime constant OOM

//-XX:PermSize=10M -XX:MaxPermSize=10M

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class RuntimeConstantOOM {

public static void main(String[] args) {

List list = new ArrayList();

int i = 1000000;

while(i– > 0) {

list.add(String.valueOf(i).intern());

}
}
}
JVM内存模型

【程序计数器】

线程私有,一个线程一个,有多个;

如果当前线程执行Java方法,则程序计数器是Java字节码地址;如果是执行本地native方法,则为空 ;

【Java虚拟机栈】

线程私有,一个线程一个,有多个;

-Xss 栈的大小,决定了可被调用的函数的深度;

private int count = 0;

public void recursion() {

count++;

recursion();

}

E:JVM>java -Xss1m TestStack

deep of stack is 39828

E:JVM>java -Xss2m TestStack

deep of stack is 83699

E:JVM>java -Xss3m TestStack

deep of stack is 127877

【Java虚拟机栈-栈帧】

一个函数一个,保存局部变量表、操作数栈、动态链接方法和返回地址等;

栈的-Xss一定的情况下,栈帧越大,可被调用的函数深度越小;

private int count = 0;

public void recursion(long a, long b, long c) throws InterruptedException {

long d = 0, e = 0, f = 0;

count++;

recursion(a, b, c);

}

E:JVM>java -Xss1m TestStack2

deep of stack is 21942

E:JVM>java -Xss2m TestStack2

deep of stack is 48163

E:JVM>java -Xss3m TestStack2

deep of stack is 74429

【Java虚拟机栈-栈帧-局部变量表】

Slot是局部变量表中存放变量的最小单位,一般32或64位,boolean, byte,char,int, short, float, reference, returnAddress等被一个32位的slot存放;double和long可被slot存放;但这些都不一定;

public void test1() {

{

<span class="keyword" style="color: #859900;">long</span> a = <span class="number" style="color: #2aa198;">0</span>;

}

long b = 0;

}

public void test2() {

long a = 0;

long b = 0;

}

用jclasslib查看class结构:

test1():

Maximum local variables: 3 long占两个slot,b复用a,加上this占1个,一共3个;

test2():

Maximum local variables: 5 a, b共占2 * 2个slot,加this 1个,共5个;

局部变量表中的局部变量所指向的对象,在退出作用域后,也无法被GC,除非被另一个局部变量复用了对应的Slot或者被置为NULL;

详见:把用完的变量赋null的意义;

-XX:+PrintGCDetails 查看GC详情 -XX:+PrintGC 查看简要GC情况

test1():

{

byte[] b = new byte[6 * 1024 * 1024];

}

System.gc();

test2():

{

byte[] b = new byte[6 * 1024 * 1024];

b = null;

}

System.gc();

E:JVM>java -XX:+PrintGCDetails TestLocalGC

[Full GC (System) [Tenured: 6144K->6294K(10944K), 0.0040662 secs] 6457K->6294K(15872K), [Perm : 2131K->2131K(12288K)], 0.0047115 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]

E:JVM>java -XX:+PrintGCDetails TestLocalGC

[Full GC (System) [Tenured: 6144K->150K(10944K), 0.0040463 secs] 6457K->150K(15872K), [Perm : 2131K->2131K(12288K)], 0.0045470 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

为啥上面6MB的空间就会被Full GC?是新生代太小么?不是,是调用System.gc()就是进行Full GC;

【Java堆】

eden[‘i:dən], s0 survivor space0, from space, s1 survivor space1, to space

tenured [tɛnjɚd]

s0和s1中的对象达到一定幸存次数,再进入tenured区;

byte[] b1 = new byte[1024 * 1024 / 2];

byte[] b2 = new byte[1024 1024 8];

b2 = null;

b2 = new byte[1024 1024 8];

System.gc();

不使用System.gc,堆要设置足够大,不然依然会 Full GC:

E:JVM>java -XX:+PrintGC -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -Xms40M -Xmx40M -Xmn20M TestHeapGC

[GC 9031K->662K(38912K), 0.0107908 secs]

使用了System.gc,即使堆足够大,也会Full GC:

E:JVM>java -XX:+PrintGC -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -Xms40M -Xmx40M -Xmn20M TestHeapGC

[GC 9031K->662K(38912K), 0.0168696 secs]

[Full GC 8854K->8854K(38912K), 0.0126251 secs]

【方法区(Permanent, [‘pɜːm(ə)nənt]】

类信息,常量池,域信息,方法信息等;

1, 对常量池的GC:

int i = 0;

while(true) {
String t = String.valueOf(i++).intern();
}

E:JVM>java -XX:PermSize=2m -XX:MaxPermSize=4m -XX:+PrintGCDetails PermGenGC

[Full GC [Tenured: 150K->150K(10944K), 0.0069401 secs] 2368K->150K(15936K),

[Perm : 4095K->2203K(4096K)], 0.0076854 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]

String是加入常量池的,Perm不断地被GC;

2, 对类信息的回收,对动态生成类的程序敏感,最好使用自定义的ClassLoader

一个类信息被GC的条件是:所有该类的实例被GC了+装载该类的ClassLoader被回收 ;

所以动态生成字节码的技术总是与自定义ClassLoader配合使用;不然Perm区的GC有可能不够及时,被动态生成的类信息占满了,但由于ClassLoader没有回收,使得这些类信息也无法回收;

【虚拟机栈空间和堆空间的关系:竞争】

虚拟机栈空间(-Xss) * 线程数 = 内存 – 堆空间

虚拟机栈中的栈帧空间 * 函数调用深度 = 虚拟机栈空间

0 0
原创粉丝点击