`
greemranqq
  • 浏览: 981162 次
  • 性别: Icon_minigender_1
  • 来自: 重庆
社区版块
存档分类
最新评论
阅读更多

一、序言

       这里会分析ThreadLocal 源码以及原理,和它的正确使用原则,还有我们应用过的地方,帮助大家更深刻的理解这个类的使用。

       ThreadLocal  在JDK1.2的版本的就提供的一个类,它提供了一种新的思路去解决多线程问题,同时ThreadLocal  不是线程类,仅仅是一个线程的变量副本,他是如何来实现这个功能的呢,我们从源码进行分析。

 

 

二、源码分析

       

// 仅仅是一个单独的类,没有除Object外的其他父类
public class ThreadLocal<T> {...}

   

 

    我们还是从基本的API 方法分析,其中包括get(),initialValue(),remove(),set(T value) 方法:

   2.1  get() : 返回此线程局部变量的当前线程副本中的值

 

   

  public T get() {
        // 回先获得当前线程
        Thread t = Thread.currentThread();
        // 然后获得线程t 里面的变量threadLocals,看下面的方法getMap()
        // 这里变量其实是个map,具体实现,我们先分析几本原理,再分析实现
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            // 然后获得当前里面的值,这里可以参考hashMap 的实现原理
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null)
                return (T)e.value;
        }
        // 如果没有获得值,表示还没set 值,就会返回初始值
        // 这里是初始化ThreadLocal 的方法。
        return setInitialValue();
    }
  // 注意:这里是从Thread 获得的变量
  // 关于ThreadLocalMap是个什么东西,我们先介绍几本原理,在详细分析。
  ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }

   2.2 initialValue()  返回此线程局部变量的当前线程的“初始值”。

    这是源码,我们可以这样使用

 protected T initialValue() {
        return null;
    }

   

// 这是API 介绍的,我们可以按类似的方式给它赋初始值
// 可以看出这个是protected 的initialValue 准备让子类重写的
// 这个值仅仅作为初始化用,当第一次执行set 方法的时候,就会覆盖这个值,但是初始值始终存在。
// 让你remove 的之后,在次进行get 就会返回初始值
private static final ThreadLocal <Integer> uniqueNum = 
        new ThreadLocal<Integer> () {
            @Override 
            protected Integer initialValue() {
                return 1;
        }
    };

 

 

   2.3 set(T value) 将此线程局部变量的当前线程副本中的值设置为指定值。

 

    

public void set(T value) {
        // 同样是获得当线程,和里面的变量map.然后set 值
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            
            createMap(t, value);
    }

// 这里变量第一次为 null 的情况,会创建一个新对象。
 void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
}

 

 

    

// 可以看出这是ThreadLocal 的内部类 
static class ThreadLocalMap {
 // 内部类里面还构建了一个内部类,这个内部类
 static class Entry extends WeakReference<ThreadLocal> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal k, Object v) {
                super(k);
                value = v;
            }
        }

}

 

 

    2.4 remove() 移除此线程局部变量当前线程的值。

   

public void remove() {
         // 这里也是传入当前线程,然后操作变量map 
         ThreadLocalMap m = getMap(Thread.currentThread());
         if (m != null)
             m.remove(this);
     }

 

 

    结论:从上面的基本方法可以看出,我们都是获得Thread.currentThread(),然后操作里面的一个ThreadLocalMap类型的变量完成了保存对象的任务,这也就完成了和线程之间的绑定,至于为什么是线程的一个变量呢,我这里再帖一下Thread的源码。

   

public class Thread implements Runnable {
 /* ThreadLocal values pertaining to this thread. This map is maintained
  *  by the ThreadLocal class. */
 // 可以看出Thread 类,对 ThreadLocalMap 的引用。
 ThreadLocal.ThreadLocalMap threadLocals = null;

}

    那么我们现在看看 ThreadLocalMap 到底是干嘛的呢?

 

 

三、ThreadLocalMap  源码分析

        

// 这是ThreadLocal的一个内部类
static class ThreadLocalMap {
        // 这又是一个内部类,继承与弱引用,至于弱引用这里暂时不详细介绍
        // 可以参考JVM 里面的的引用类型 和 GC回收机制
        // 看过hashMap 源码的人,肯定比较熟悉这种写法
        // 实现的是一种K,V 的内部类机制
        static class Entry extends WeakReference<ThreadLocal> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal k, Object v) {
                super(k);
                value = v;
            }
        }
}

 

 

   3.1 构造函数

   

 // 这里参考hashMap 的方式设计,就不多多介绍了
 // 一个放着entry  的数组table 
 ThreadLocalMap(ThreadLocal firstKey, Object firstValue) {
            table = new Entry[INITIAL_CAPACITY];
            // 这里hashCode 的取值比较特别,我们单独分析
            // 根据hashCode 和长度取余运算
            int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
            table[i] = new Entry(firstKey, firstValue);
            size = 1;
            setThreshold(INITIAL_CAPACITY);
        }

    3.2 特殊的hashCode 值

// 这里看出,这个hashCode 值在创建ThreadLocal 对象的时候就创建了
private final int threadLocalHashCode = nextHashCode();

// 1640531527
// 这里为什么要用这个数字,我还真不清楚,但是能确定是为了更均匀的分布
// 这个和hashMap 的 hashCode 计算方式一样,应该都有进行专门的测试,以后再研究这个
private static final int HASH_INCREMENT = 0x61c88647;
 
private static AtomicInteger nextHashCode = new AtomicInteger();   
// 这里我们可以看出hashCode 居然是不停的加上HASH_INCREMENT 进行
private static int nextHashCode() {
	return nextHashCode.getAndAdd(HASH_INCREMENT); 
    }

 

    3.3 set 方法:这里是内部类如何存放值得

   

private void set(ThreadLocal key, Object value) {
            Entry[] tab = table;
            int len = tab.length;
            int i = key.threadLocalHashCode & (len-1);

            for (Entry e = tab[i];e != null;e = tab[i = nextIndex(i, len)]) {
              // 这里是先取出值
              ThreadLocal k = e.get();
               // 判断是否是同一个对象,如果是就覆盖这个当前值
                if (k == key) {
                    e.value = value;
                    return;
                }
                // 如果存放的k为null,可能被回收了,也就是过去了嘛
                // 这里就把以前这个位置的信息覆盖,以前的就没了。
                if (k == null) {
                    replaceStaleEntry(key, value, i);
                    return;
                }
            }
            // 都没有的情况下,从新创建一个
            tab[i] = new Entry(key, value);
            int sz = ++size;
             // 这里又会检测过期元素,并删除过期的,
             // 如果没有过期的,超过限制范围,就会扩容
            if (!cleanSomeSlots(i, sz) && sz >= threshold)
                rehash();
        }

       

 

 

    3.4 getEntry 方法:返回这个对象,就能获得值

   

   private Entry getEntry(ThreadLocal key) {
            int i = key.threadLocalHashCode & (table.length - 1);
            Entry e = table[i];           
            if (e != null && e.get() == key)
                // 找到了就返回
                return e;
            else
               // 没找到就遍历查找
               return getEntryAfterMiss(key, i, e);
        }

 private Entry getEntryAfterMiss(ThreadLocal key, int i, Entry e) {
            Entry[] tab = table;
            int len = tab.length;
            
            while (e != null) {
                ThreadLocal k = e.get();
                if (k == key)
                    return e;
                if (k == null)
                    // 删除空的
                    expungeStaleEntry(i);
                else
                    // 继续找,这里应该是用的线性探查法解决冲突的
                    i = nextIndex(i, len);
                e = tab[i];
            }
            return null;
        }

    3.5 remove 方法,删除元素

   private void remove(ThreadLocal key) {
            Entry[] tab = table;
            int len = tab.length;
            int i = key.threadLocalHashCode & (len-1);
            for (Entry e = tab[i];
		 e != null;
		 e = tab[i = nextIndex(i, len)]) {                
                if (e.get() == key) {
                    // 这里删除仅仅是引用设置为null 就行了。
                    e.clear();
                    expungeStaleEntry(i);
                    return;
                }
            }
        }

 

 

   结论:我们可以看出内部类的实现,是对一个继承了弱应用的的K.V entry 的操作。我们看出每一次操作都会去检测一那些对象为空了,然后进行删除。同时也看出了它的操作虽然类似map,但是却不是操作的map.关于弱引用的作用我这里简答描述是:当某对象已经不使用了,为null的情况下,为了不让强引用占有这个空间,那么弱引用能加快GC的一种手段。

 

 

四、应用场景

       4.1 描述: 

            ThreadLocal 提供一种新的思路去解决多线程问题,是解决什么问题呢?又是如何解决呢?

            在多线程并发中,我们常常遇到的问题是共享资源的操作,常用的办法是加锁机制,但是这种机制负面影响比较多。然后ThreadLocal 提供一种保存带状态的共享变量的副本的方式,来隔绝各个线程中带来的影响,以空间换时间。

 

       4.2 实例1:

             比如:Spring 中,我们常用的单利模式,假设我一个Count 类,里面有个属性num,用来观察某一时刻的浏览数量。        

 

public static class Count{
   public static  int num = 0;
}

           在多线程中,不是使用加锁等手段,如果A 线程在A时刻先将num 变成100,然后准备访问之前,这时候B线程在B时刻将num 变成200.然后在由A线程去访问,返回200,明显是有错误的。当然创建多个Count也可以解决,那么我们在不创建的多个Count 对象的情况下,如何保证安全呢?

 

           我们可以在A B线程创建一个ThreadLocal 变量,然后当A线程将num 变成100的时候,同时将这这个100的值,存放在ThreadLocal 里面,那么下次访问就从变量里面提取,就不会错误了,当然由于业务原因,这里例子不太好,但是道理类似。

 

           实例 2:这里我copy 的网上hibernate 里面Session 的例子:

            

    private static final ThreadLocal threadSession = new ThreadLocal();

    public static Session getSession() throws InfrastructureException {
        Session s = (Session) threadSession.get();
        try {
            if (s == null) {
                s = getSessionFactory().openSession();
                threadSession.set(s);
            }
        } catch (HibernateException ex) {
            throw new InfrastructureException(ex);
        }
        return s;
    }

       可以看出,每个线程保存一份session,方便下次使用,同时各自有各自的状态,同时也会随着线程的退出而快速的清理这个副本。

 

        实例3 :这里简单的原理,我直接贴下代码,大家感受一下:

         

    static class A {
		public boolean  flag = false;
	}
   
	public static void main(String[] args) throws InterruptedException {	
		ThreadLocal thread =  new ThreadLocal();
		A a = new A();		
	        thread.set(a.flag);
		a.flag = true;
                // 虽然一个线程,但是变量值是可以做个隔离的
		System.out.println(thread.get() +":"+a.flag);
	}

 

 

五、Thread 和 ThreadLocal 关系和原理:

     

    // 这里我们已经说过每个Thread 里面都有 引用,变量副本主要是通过
    // 操作内部类进行实现的
    ThreadLocal.ThreadLocalMap threadLocals = null;
    // 这个变量作用是为可以子线程访问父线程的变量而准备的。
    // 这里我们暂时不做详细介绍,原理都差不多
    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;

     

    下面我再看看线程退出的时候,默认会调exit()方法:

    

   private void exit() {
	if (group != null) {
	    group.remove(this);
	    group = null;
	}
        // 这里看到, 线程退出,会将这个设置为空
        // 由前的源码分析可以看出,当key 为空的时候会被清除
        // 加快GC
        threadLocals = null;
        inheritableThreadLocals = null;
        inheritedAccessControlContext = null;
        blocker = null;
        uncaughtExceptionHandler = null;
    }

 

  

总结:

        1.ThreadLocal我仅仅分析了大部分源码, 还有一部分没分析,感兴趣的朋友可以共同讨论

        2.强调两点:

           a.ThreadLocal不是线程,仅仅是存放变量的的一个副本,每个Thread都有引用

           b.ThreadLocal实现不是map,虽然类似!它主要用来存放有变化的状态,用于隔离多线程的影响

        3.JDK 建议创建以static final 为主,节约内存。

        4.关于使用原则,只能大家对原理机制理解了,就知道什么地方该用了

        5.上面仅限于自己理解,如果有异议的地方请指出,还有不懂,或者其他想法的朋友,希望多沟通讨论。

 

2
0
分享到:
评论
2 楼 greemranqq 2014-03-24  
washingtonDC 写道
虽然一下子不能理解,但佩服楼主的探索精神,支持一个,

这个我不是用大白话写的,因此如果以前你没用过,或者没分析过,一下子很难明白,仅仅知道如何使用就行了。这个希望能对想深入研究的朋友提供一点思路,多讨论多分析,实践,相信理解得更好。
1 楼 washingtonDC 2014-03-24  
虽然一下子不能理解,但佩服楼主的探索精神,支持一个,

相关推荐

    养老院管理系统:SpringBoot与Vue前后端不分离架构的设计与实现

    内容概要:本文详细介绍了基于SpringBoot和Vue开发的养老院管理系统的具体实现细节。该系统采用前后端不分离的架构,旨在快速迭代并满足中小项目的开发需求。文中涵盖了多个关键技术点,如数据库设计(组合唯一约束、触发器)、定时任务(@Scheduled、@Async)、前端数据绑定(Vue的条件渲染和动态class绑定)、权限控制(RBAC模型、自定义注解)以及报表导出(SXSSFWorkbook流式导出)。此外,还讨论了开发过程中遇到的一些常见问题及其解决方案,如CSRF防护、静态资源配置、表单提交冲突等。 适合人群:具备一定Java和前端开发经验的研发人员,尤其是对SpringBoot和Vue有一定了解的开发者。 使用场景及目标:适用于需要快速开发中小型管理系统的团队,帮助他们理解如何利用SpringBoot和Vue进行全栈开发,掌握前后端不分离架构的优势和注意事项。 其他说明:文章不仅提供了详细的代码示例和技术要点,还分享了许多实用的小技巧和避坑指南,有助于提高开发效率和系统稳定性。

    家族企业如何应对人才流失问题?.doc

    家族企业如何应对人才流失问题?

    员工关怀制度.doc

    员工关怀制度.doc

    路径规划领域中基于排序搜索的蚁群算法优化及其应用

    内容概要:本文详细探讨了对传统蚁群算法进行改进的方法,特别是在路径规划领域的应用。主要改进措施包括:采用排序搜索机制,即在每轮迭代后对所有路径按长度排序并只强化前20%的优质路径;调整信息素更新规则,如引入动态蒸发系数和分级强化策略;优化路径选择策略,增加排序权重因子;以及实现动态地图调整,使算法能够快速适应环境变化。实验结果显示,改进后的算法在收敛速度上有显著提升,在复杂地形中的表现更加稳健。 适合人群:从事路径规划研究的技术人员、算法工程师、科研工作者。 使用场景及目标:适用于需要高效路径规划的应用场景,如物流配送、机器人导航、自动驾驶等领域。目标是提高路径规划的效率和准确性,减少不必要的迂回路径,确保在动态环境中快速响应变化。 其他说明:改进后的蚁群算法不仅提高了收敛速度,还增强了对复杂环境的适应能力。建议在实际应用中结合可视化工具进行调参,以便更好地观察和优化蚂蚁的探索轨迹。此外,还需注意避免过度依赖排序机制而导致的过拟合问题。

    基于PSO算法的配电网分布式光伏选址定容优化及其Matlab实现

    内容概要:本文详细介绍了利用粒子群优化(PSO)算法解决配电网中分布式光伏系统的选址与定容问题的方法。首先阐述了问题背景,即在复杂的配电网环境中选择合适的光伏安装位置和确定合理的装机容量,以降低网损、减小电压偏差并提高光伏消纳效率。接着展示了具体的PSO算法实现流程,包括粒子初始化、适应度函数构建、粒子位置更新规则以及越界处理机制等关键技术细节。文中还讨论了目标函数的设计思路,将多个相互制约的目标如网损、电压偏差和光伏消纳通过加权方式整合为单一评价标准。此外,作者分享了一些实践经验,例如采用前推回代法进行快速潮流计算,针对特定应用场景调整权重系数,以及引入随机波动模型模拟光伏出力特性。最终实验结果显示,经过优化后的方案能够显著提升系统的整体性能。 适用人群:从事电力系统规划与设计的专业人士,尤其是那些需要处理分布式能源集成问题的研究人员和技术人员。 使用场景及目标:适用于希望深入了解如何运用智能优化算法解决实际工程难题的人士;旨在帮助读者掌握PSO算法的具体应用方法,从而更好地应对配电网中分布式光伏系统的选址定容挑战。 其他说明:文中提供了完整的Matlab源代码片段,便于读者理解和复现研究结果;同时也提到了一些潜在改进方向,鼓励进一步探索和创新。

    Prius2004永磁同步电机设计:从Excel到MotorCAD的全流程解析与实战技巧

    内容概要:本文详细介绍了丰田Prius2004永磁同步电机的设计流程,涵盖从初始参数计算到最终温升仿真的各个环节。首先利用Excel进行基本参数计算,如铁芯叠厚、定子外径等,确保设计符合预期性能。接着使用Maxwell进行参数化仿真,通过Python脚本自动化调整磁钢尺寸和其他关键参数,优化电机性能并减少齿槽转矩。随后借助橡树岭实验室提供的实测数据验证仿真结果,确保模型准确性。最后采用MotorCAD进行温升仿真,优化冷却系统设计,确保电机运行安全可靠。文中还分享了许多实用技巧,如如何正确设置材料参数、避免常见的仿真错误等。 适合人群:从事电机设计的专业工程师和技术人员,尤其是对永磁同步电机设计感兴趣的读者。 使用场景及目标:适用于希望深入了解永磁同步电机设计全过程的技术人员,帮助他们在实际工作中提高设计效率和精度,解决常见问题,优化设计方案。 其他说明:文章提供了丰富的实战经验和具体的操作步骤,强调了理论与实践相结合的重要性。同时提醒读者注意一些容易忽视的细节,如材料参数的选择和仿真模型的准确性。

    基于DSP28335的单相逆变器设计方案与实现:涵盖ADC采样、PWM控制、锁相环及保护机制

    内容概要:本文详细介绍了基于DSP28335的单相逆变器的设计与实现,涵盖了多个关键技术模块。首先,ADC采样模块用于获取输入电压和电流的数据,确保后续控制的准确性。接着,PWM控制模块负责生成精确的脉宽调制信号,控制逆变器的工作状态。液晶显示模块则用于实时展示电压、电流等重要参数。单相锁相环电路实现了电网电压的频率和相位同步,确保逆变器输出的稳定性。最后,电路保护程序提供了过流保护等功能,保障系统的安全性。每个模块都有详细的代码示例和技术要点解析。 适合人群:具备一定嵌入式系统和电力电子基础知识的研发人员,尤其是对DSP28335感兴趣的工程师。 使用场景及目标:适用于单相逆变器项目的开发,帮助开发者理解和掌握各个模块的具体实现方法,提高系统的可靠性和性能。 其他说明:文中不仅提供了具体的代码实现,还分享了许多调试经验和常见问题的解决方案,有助于读者更好地理解和应用相关技术。

    SecureCRT安装包

    SecureCRT安装包

    C# WPF MVVM架构下的大屏看板3D可视化开发指南

    内容概要:本文详细介绍了如何利用C#、WPF和MVVM模式构建一个大屏看板3D可视化系统。主要内容涵盖WPF编程设计、自定义工业控件、数据库设计、MVVM架构应用以及典型的三层架构设计。文中不仅提供了具体的代码实例,还讨论了数据库连接配置、3D模型绑定、依赖属性注册等关键技术细节。此外,文章强调了项目开发过程中需要注意的问题,如3D坐标系换算、MVVM中命令传递、数据库连接字符串加密等。 适合人群:具备一定C#编程基础,对WPF和MVVM模式有一定了解的研发人员。 使用场景及目标:适用于希望深入了解WPF和MVVM模式在实际项目中应用的开发者,特别是那些从事工业控制系统、数据可视化平台开发的专业人士。通过学习本文,读者可以掌握如何构建高效、稳定的大屏看板3D可视化系统。 其他说明:本文提供的设计方案和技术实现方式,可以帮助开发者更好地理解和应用WPF和MVVM模式,同时也能为相关领域的项目开发提供有价值的参考。

    基于java SSM 框架的酒店管理系统.zip

    基于ssm的系统设计,包含sql文件(Spring+SpringMVC+MyBatis)

    非厄米超表面双参数传感器的COMSOL建模与应用

    内容概要:本文详细介绍了利用COMSOL进行非厄米超表面双参数传感器的设计与实现。首先,通过构建超表面单元并引入虚部折射率,实现了PT对称系统的增益-损耗交替分布。接着,通过频域扫描和参数化扫描,捕捉到了复频率空间中的能级劈裂现象,并找到了奇异点(Exceptional Point),从而显著提高了传感器对微小扰动的敏感度。此外,文章探讨了双参数检测的独特优势,如解耦温度和折射率变化的能力,并展示了其在病毒检测、工业流程监控等领域的潜在应用。 适合人群:从事光学传感器研究的专业人士,尤其是对非厄米系统和COMSOL仿真感兴趣的科研人员。 使用场景及目标:适用于需要高精度、多参数检测的应用场合,如生物医学检测、环境监测等。目标是提高传感器的灵敏度和分辨率,解决传统传感器中存在的参数交叉敏感问题。 其他说明:文中提供了详细的建模步骤和代码片段,帮助读者理解和重现实验结果。同时,强调了在建模过程中需要注意的关键技术和常见问题,如网格划分、参数设置等。

    怎样健全员工福利体系.docx

    怎样健全员工福利体系.docx

    离职证明范本.doc

    离职证明范本.doc

    6538b79724855900a9c930904a302920.part6

    6538b79724855900a9c930904a302920.part6

    员工离职单.doc

    员工离职单.doc

    COMSOL中超材料异常折射仿真的关键技术与实现

    内容概要:本文详细介绍了在COMSOL中进行超材料异常折射仿真的关键技术。首先解释了异常折射现象及其产生的原因,接着通过具体代码展示了如何利用相位梯度和结构色散精确计算折射角。文中还讨论了边界条件的设置、网格划分的优化以及参数化扫描的应用。此外,提供了多个实用脚本和技巧,帮助提高仿真的精度和效率。最后强调了验证结果的重要性和一些常见的注意事项。 适合人群:从事电磁仿真研究的专业人士,尤其是对超材料和异常折射感兴趣的科研人员和技术开发者。 使用场景及目标:适用于需要深入理解和解决超材料中异常折射问题的研究项目。主要目标是掌握COMSOL中异常折射仿真的完整流程,确保仿真结果的准确性并优化计算性能。 其他说明:文章不仅提供了详细的代码示例和技术细节,还分享了许多实践经验,有助于读者更好地应对实际仿真过程中可能出现的问题。

    招聘工作数据分析表.xls

    招聘工作数据分析表.xls

    platform-tools-latest-windows.zip

    platform-tools-latest-windows.zip

    个人资料临时存储QT资源

    个人资料临时存储QT资源

    微电网三相交流下垂控制技术详解:传统阻感型输出有功、无功及频率波形分析

    内容概要:本文详细介绍了微电网中三相交流下垂控制的工作原理和技术细节。首先,通过Matlab/Simulink搭建模型,展示了传统阻感型线路下垂特性的实现方法,特别是有功-频率和无功-电压下垂曲线的解析。文中强调了关键参数Kp和Kq的选择及其对系统稳定性的影响,并通过具体的仿真案例展示了不同参数设置下的动态响应。此外,文章讨论了波形分析中的注意事项,如谐波成分、滤波器设计以及虚拟阻抗的应用。最后,通过Python和C语言实现了下垂控制器的代码示例,进一步解释了实际工程中的实现细节。 适合人群:从事微电网研究和开发的技术人员,尤其是对下垂控制感兴趣的电气工程师和研究人员。 使用场景及目标:适用于希望深入了解微电网下垂控制原理及其实际应用的研究人员和技术人员。目标是帮助读者掌握下垂控制的核心概念和技术实现,提高在实际工程项目中的调试和优化能力。 其他说明:文章不仅提供了理论分析,还包括了大量的仿真代码和波形图,使读者能够更好地理解和验证所学内容。同时,文中提到的实际调试经验和常见错误也为初学者提供了宝贵的指导。

Global site tag (gtag.js) - Google Analytics