一次非典型的Netty内存泄露案例复盘

news2024/10/5 13:00:29

背景

作为后端开发相信大家或多或少都接触过Nettty,说起Netty真实又爱又恨,因为基于它可以很简单的开发高性能的Java网络通信服务,但同时要是不小心就会出现各种奇奇怪怪的问题,特别是由于特殊的内存管理机制很容易出现内存泄漏问题即OOM问题。这些天就遇到了类似的问题,排查和解决起来确实废了不小功夫,特别这里记录一下。

其实Netty内存泄漏问题一般都比较好解决,典型的就是各种ByteBuf没有被Release,但如果遇到非典型的问题就比较考验技术人员功力的时候了。

现象

监控系统显示有一个服务在下班前突然自己挂掉了。我看了一下这个服务今天确实调整了一下日志配置,配置有错误,没办法与logstash通讯,但这个应该不会导致OOM问题才对。

于是查了一下日志显示最后是OOM了才,很明显是由于内存泄漏造成的。

日志如下:

image-20230115180147854

为了确认问题的可重复性,又重启了服务,第二天上班又用htop看了一下,确实内存占用增加了一块,证实并非偶发原因,应该是代码中存在问题导致。于是开启了定位的过程。

定位过程

获取Dump文件

由于在生产系统中,直接去定位会有影响,还是研究一下dump文件

jps找到问题服务的pid,jmap导出内存问题件,zip压缩一下弄到开发机器。

分析dump文件

既然已经有了dump文件,就选择一个工具来分析就好了,我选择的是比较好使的HeroDump,至少界面看着比较好看还有自动提示。

image-20230112160218956

明显这407个NioEventLoop不正常,一个1M都能有400M+了,占了90%+的内存,就他没跑了。

过了一会第二次的快照分析也完成了,结果相似,而且NioEventLoop更是暴涨到1339了。

image-20230112160158339

可以看到NioEventLoop持续的在积累。

继续查看实际占用内存的数据类型

image-20230115204036928

这些byte[]应该是NioEventLoop下属的数据。

到这里可以看出主要问题是NioEventLoop的垃圾回收机制没有发挥作用或是回收的速度没有新增的速度快,导致这个NioEventLoop持续增长。

这个结果反映了是不是一个好定位的问题,因为问题往往都是出现在业务代码中,而现在定位的结果却显示Netty本身的 核心NioEventLoop有问题。而Netty本身存在这么重大缺陷几乎是不可能的,这条线是追查不下去了。

代码分析

于是只能分析负责客户端连接的代码


@Service
@Slf4j
public class ClientModeConnectionManager {

    /**
     * 定时刷新连接情况
     */
    @Scheduled(fixedRate = 30 * 1000L)
    public void refreshConnection() {

        //没有初始化完成就跳出
        if (!flag) {
            return;
        }
        //查询配置文件中全部的设备信息
        Map<String, String> all = configService.getAllByType("server");

        //断开不再启用的设备
        connectionCacheService.getAll().forEach((puid, channel) -> {
            if (configService.findByPuid(puid) == null) {
                disconnect(puid);
            }
        });
        all.forEach((address, puid) -> {
            if (StringUtils.isEmpty(puid) || StringUtils.isEmpty(address)) {
                log.error("配置错误{}-{}", puid, address);
                return;
            }
            //查询在配置文件中未连接的设备
            if (!isConnected(puid)) {
                executorService.submit(() -> {
                    try {
                        connectToFacility(address, puid);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                });
            }
        });
    }

    private boolean isConnected(String puid) {

        if (connectionCacheService.isConnected(puid)) {
            Channel channel = connectionCacheService.getChannel(puid);
            if (channel.isActive()) {
                return true;
            } else {
                disconnect(puid);
                return false;
            }
        } else {
            disconnect(puid);
            return false;
        }
    }

    private void disconnect(String puid) {
        Channel channel = connectionCacheService.getChannel(puid);
        if (channel != null) {
            try {
                channel.close().sync();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            connectionCacheService.clearChannel(puid);
        }

        EventLoopGroup eventExecutors = clients.get(puid);
        if (eventExecutors != null) {
            eventExecutors.shutdownGracefully();
        }
    }


    @PreDestroy
    public void stop() throws InterruptedException {
        Map<String, String> all = configService.getAllByType("server");
        for (String puid : all.values()) {
            if (StringUtils.isEmpty(puid)) {
                log.error("配置错误{}", puid);
                continue;
            }
            //查询在配置文件中未连接的设备
            if (connectionCacheService.isConnected(puid)) {
                Channel channel = connectionCacheService.getChannel(puid);
                channel.closeFuture().sync();
            }
            EventLoopGroup eventExecutors = clients.get(puid);
            if (eventExecutors != null) {
                eventExecutors.shutdownGracefully();
            }

        }
    }

    private synchronized void connectToFacility(String address, String puid) throws InterruptedException {
        log.info("开始尝试客户端模式连接{},PUID:{}", address, puid);
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        String[] array = address.split(":");
        String ip = array[0];
        int port = Integer.parseInt(array[1]);
        Bootstrap bs = new Bootstrap();

        bs.group(bossGroup)
                .channel(NioSocketChannel.class)
                .option(ChannelOption.SO_KEEPALIVE, true)
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
                .handler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel socketChannel) {
                        // 处理来自服务端的响应信息
                        socketChannel.pipeline().addLast(channelHandlerAdapter);
                    }
                });

        // 客户端开启
        ChannelFuture cf = bs.connect(ip, port).sync();
        log.info("主机{}连接成功,客户端模式,PUID:{}", ip, puid);

        clients.put(puid, bossGroup);
        Channel oldChannel = connectionCacheService.getChannel(puid);
        if (oldChannel != null && (!cf.channel().equals(oldChannel))) {
            try {
                oldChannel.closeFuture().sync();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        connectionCacheService.setChannel(puid, cf.channel());
    }
}

从代码中可以看出来,这个服务跟大部分Netty使用场景不太一样,大部分netty都是用作服务端了,但这里是用作客户端。这就可能是问题所在,Netty客户端开发人员没啥经验就很可能出现比较大的设计问题。

从上面的分析可以得到的结论应该是客户端管理服务,也就是ClientModeConnectionManager中出现的问题。

通过对ClientModeConnectionManager的进一步分析,同时对比各种示例程序,发现有两个大的不同点。

  1. EventLoopGroup一般不作为局部变量使用
  2. 最后要释放EventLoopGroup,也就是执行 eventLoopGroup.shutdownGracefully(),并不是要释放Bootstrap。

当然如果不是神仙或是对Netty极为熟悉的大佬是不可能一次性找到问题的,这个发现是通过逐项的修改对比和实验才确定的。

最终为了更好的管理EventLoopGroup生命周期,对代码进行一定的调整,将连接停止逻辑独立为ConnectThread(其实叫ClientThread更准确)。具体请见下面示例和模板。

逻辑反向闭环

这个是定位问题非常重要的一步就是用你得到的结论,再反向按照逻辑推出出现的问题,如果逻辑不通那必定还存在其他问题。

逻辑推演过程:

  1. EventLoopGroup虽然是局部变量但其实NioEventLoop是在独立的线程运行无法被垃圾回收
  2. 客户端管理代码没有在服务端连接异常的时候释放NioEventLoop也就是没有执行eventLoopGroup.shutdownGracefully(),导致eventLoopGroup以及NioEventLoop相关的内存没有得到释放
  3. ClientModeConnectionManager每30s会进行一次刷新,不断的尝试重新连接服务端,但由于特殊原因服务端一直不在线,因此会一直创建eventLoopGroup和对应的NioEventLoop
  4. NioEventLoop及其内部数据的内存不断积累最终导致OOM

修复确认

就算是逻辑反向闭环了,也需要实验来最终验证,最后还是要测试说了算的。

修复前

image-20230112151316926

image-202301121515015548小时内必OOM

修复后

在这里插入图片描述

在这里插入图片描述

持续测试

在生产系统上6.6%至少持续了5小时。

在这里插入图片描述

28小时后维持在6.7

在这里插入图片描述

45小时候依然在6.7%

在这里插入图片描述

image-20230115095832540

改善措施

启动方式改进

在启动命令行中添加-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:\tmp参数,保证异常挂掉时能够生成dump文件,好第一时间定位问题。

建立Netty代码模板

形成标准的Netty客户端和服务端模板避免此类问题再次产生

客户端模板

ClientManager.java


/**
 * 服务启动监听器
 *
 * @author ZEW
 */
@Service
@Slf4j
public class ClientModeConnectionManager {

    /**
     * 定时刷新连接情况
     */
    @Scheduled(fixedRate = 30 * 1000L)
    public void refreshConnection() {

        //没有初始化完成就跳出
        if (!flag) {
            return;
        }
        //查询配置文件中全部的设备信息
        Map<String, String> all = configService.getAllByType("server");

        //断开不再启用的设备
        connectionCacheService.getAll().forEach((puid, channel) -> {
            if (configService.findByPuid(puid) == null) {
                disconnect(puid);
            }
        });
        all.forEach((address, puid) -> {
            if (StringUtils.isEmpty(puid) || StringUtils.isEmpty(address)) {
                log.error("配置错误{}-{}", puid, address);
                return;
            }
            //查询在配置文件中未连接的设备
            if (!isConnected(puid)) {
                disconnect(puid);
                ConnectThread task = new ConnectThread(puid, address, connectionCacheService, rabbitTemplate, configService);
                threads.put(puid, task);
                executorService.submit(task);

            }


        });


    }

    private boolean isConnected(String puid) {
        ConnectThread connectThread = threads.get(puid);
        if (connectThread == null) {

           return false;
        }
        return connectThread.isConnect();

    }

    private void disconnect(String puid) {
        ConnectThread connectThread = threads.get(puid);
        if (connectThread != null) {

            connectThread.disconnect();
        }
        threads.remove(puid);
    }


    @PreDestroy
    public void stop() throws InterruptedException {
        Map<String, String> all = configService.getAllByType("server");
        for (String puid : all.values()) {
            if (StringUtils.isEmpty(puid)) {
                log.error("配置错误{}", puid);
                continue;
            }
            //查询在配置文件中未连接的设备
            if (connectionCacheService.isConnected(puid)) {
                Channel channel = connectionCacheService.getChannel(puid);
                channel.closeFuture().sync();
            }
            disconnect(puid);


        }
    }


}

ConnectThread.java


/**
 * @author zew
 */
@Slf4j
public class ConnectThread implements Runnable {


    @Override
    public void run() {
        try {
            //避免重复连接导致NioEventLoopGroup和NioEventLoop重复创建,内存溢出
            disconnect();
            connectToFacility(address, puid);
            if (currentChannel != null) {
                currentChannel.closeFuture().sync();
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } catch (Exception e) {
            log.error("{} CONNECT FAILED", puid, e);
            return;
        } finally {
            disconnect();
        }
        log.info("{} TRANSPORT FINISH", puid);


    }

    private void connectToFacility(String address, String puid) throws InterruptedException {
        log.info("START TO CONNECT TO {},PUID:{}", address, puid);
        String[] array = address.split(":");
        String ip = array[0];
        int port = Integer.parseInt(array[1]);
        bootstrap = new Bootstrap();
        eventLoopGroup = new NioEventLoopGroup();
        bootstrap.group(eventLoopGroup).channel(NioSocketChannel.class)
                .option(ChannelOption.SO_KEEPALIVE, true)
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, TIME_OUT)
                .handler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel socketChannel) {
                        // 处理来自服务端的响应信息
                        socketChannel.pipeline().addLast(
                                new ServerChannelHandlerAdapter(configService, connectionCacheService, rabbitTemplate));
                    }
                });

        try {
            // 客户端开启
            ChannelFuture cf = bootstrap.connect(ip, port).sync();
            if (cf.awaitUninterruptibly(TIME_OUT, TimeUnit.MILLISECONDS) && cf.isSuccess()) {
                log.info("HOST {} connect successful,CLIENT,PUID:{}", ip, puid);
                currentChannel = cf.channel();
                connectionCacheService.setChannel(puid, currentChannel);
            } else {
                log.warn("HOST {} connect failed,CLIENT,PUID:{}", ip, puid);
            }
        } catch (InterruptedException e) {
            disconnect();
            Thread.currentThread().interrupt();
        } catch (Exception e) {
            log.error("HOST {} connect failed,CLIENT,PUID:{}", ip, puid, e);
        }
    }

    public void disconnect() {
        if (currentChannel != null) {
            try {
                currentChannel.close().sync();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }

        if (eventLoopGroup != null) {
            try {
                eventLoopGroup.shutdownGracefully().sync();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            eventLoopGroup = null;
        }
        bootstrap = null;
    }

    public boolean isConnect() {
        if (currentChannel == null) {
            return false;
        }
        if (currentChannel.isActive()) {
            return true;
        } else {
            log.warn("Channel:{} is not active,then close it", puid);
            return false;
        }

    }
}

这里的客户端代码还有改进空间,比如重连机制可以作为listener单独处理。

服务端模板


/**
 * 服务启动监听器
 *
 * @author ZEW
 */
@Component
@Slf4j
public class ServerModeConnectionListener {
    @Value("${facility.connection.port:10030}")
    private Integer port;
    /**
     * 创建bootstrap
     */
    private final ServerBootstrap serverBootstrap = new ServerBootstrap();
    /**
     * BOSS
     */
    private final EventLoopGroup boss = new NioEventLoopGroup();
    /**
     * Worker
     */
    private final EventLoopGroup work = new NioEventLoopGroup();

    /**
     * 通道适配器
     */
    @Resource
    private ServerChannelHandlerAdapter channelHandlerAdapter;

    /**
     * 关闭服务器方法
     */
    @PreDestroy
    public void close() {
        log.info("关闭服务器....");
        //优雅退出
        work.shutdownGracefully();
    }

    /**
     * 开启及服务线程
     */
    @PostConstruct
    public void start() {
        ThreadPoolExecutor executorService = new ThreadPoolExecutor(4, 4, 2,
                TimeUnit.SECONDS, new LinkedBlockingDeque<>(), new DefaultThreadFactory("直连服务端模式-%d"));
        executorService.submit(() -> {
            // 从配置文件中(application.yml)获取服务端监听端口号
            serverBootstrap.group(boss, work)
                    .channel(NioServerSocketChannel.class)
                    .option(ChannelOption.SO_BACKLOG, 100)
                    .handler(new LoggingHandler(LogLevel.INFO));

            try {
                //设置事件处理
                serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel ch) {
                        ChannelPipeline pipeline = ch.pipeline();
                        pipeline.addLast(channelHandlerAdapter);
                    }
                });
                log.info("netty服务器在[{}]端口启动监听", port);
                ChannelFuture f = serverBootstrap.bind(port).sync();
                f.channel().closeFuture().sync();
            } catch (InterruptedException e) {
                log.info("[出现异常] 释放资源");

                Thread.currentThread().interrupt();
            } finally {
                work.shutdownGracefully();
            }
        });

    }
}

增加理论深度并结合实践

深入理解Netty原理和GC理论加速问题定位原因

  • 了解NioEventLoop和NioEventLoop关系
  • 了解客户端和服务端代码范式
  • 了解哪些地方需要同步处理,哪些地方可以是异步的
  • 哪些地方需要使用Sync等待

总结

OOM问题往往最令人烦躁的就是难以定位和确定是否修复,这个问题反反复复解决了一周。当然生产系统在人肉运维的情况下没有出现问题。

  • 实践出真知,定位问题的能力更能够反映技术水平
  • 多看看官方文档比较靠谱,而且往往都有例子来。
  • 单因素对比实验是在没有足够线索和理论支撑的情况下定位问题原因的有效方法。

参考资源

  1. https://netty.io/wiki/
  2. https://netty.io/4.1/xref/io/netty/example/udt/echo/bytes/ByteEchoClient.html#ByteEchoClient
  3. https://juejin.cn/post/7053793963680989198

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/165683.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

数据大佬的成长经验分享 | ​我的非典型数据分析之路

小飞象交流会哪有什么错过的人&#xff0c;会离开的都是路人。哪有什么命运不公&#xff0c;都是懒惰让你变得无能。内部交流│19期数据大佬的成长经验分享我的非典型数据分析之路data analysis●●●●分享人&#xff1a;夏宇‍在大数据、人工智能热、5G、物联网的时代&#x…

1、Mavan项目管理工具

1.1 什么是 Maven 1.1.1 什么是 Maven Maven 的正确发音是[ˈmevən]&#xff0c;而不是“马瘟”以及其他什么瘟。Maven 在美国是一个口语化的词 语&#xff0c;代表专家、内行的意思。 一个对 Maven 比较正式的定义是这么说的&#xff1a;Maven 是一个项目管理工具&#xff0…

Spring Boot学习篇(十)

Spring Boot学习篇(十) shiro安全框架使用篇(二)——登录实例(密码以密文方式存储,不含记住密码) 1.模拟注册时,生成密文到数据库中 1.1 在zlz包下创建util包,并在下面创建SHAUtil01类(初始里面无方法)和SHAUtil02类,其目录结构如下所示 1.2 两种生成密文的方式 1.2.1 自己…

一篇文章彻底搞懂折半查找法[二分查找法]算法~

算法实现的要求&#xff1a; 折半查找法又称为二分查找法&#xff0c;这种方法对待查找的列表有两个要求&#xff1a; 1&#xff1a;必须采用顺序存储结构 2&#xff1a;必须按关键字大小有序排列算法思想&#xff1a; 将表中间位置记录的关键字与查找关键字进行比较&#x…

性能测试时那些「难以启齿」的问题-CPU相关

NO.1 为什么cpu使用率可以>100%? 小白的我在进行压测的时候&#xff0c;查看服务的cpu总使用率如下&#xff0c;总使用率会超过100%&#xff0c;这个数据是怎么来的呢&#xff0c;为什么会有大于100%的情况呢&#xff1f; 作为小白的我刚开始觉得这个问题应该很基础&#x…

Go语言实现猜数字小游戏

目录 前言 一、设计思路 二、代码编写 2.1 产生随机数 2.2 用户输入数据 2.3 核心代码 三、 全部代码 四、效果图 总结 前言 最近在学习go语言&#xff0c;刚刚学完go语言的基础语法。编写了一个猜数字的小游戏来练习循环、分支语句、变量定义、输入输出等基础的go语…

4、变量与常量

目录 一、标识符和关键字 1.标识符 2.关键字 二、声明变量 三、声明常量 四、变量的有效范围 1. 成员变量 2. 局部变量 一、标识符和关键字 1.标识符 Java语言规定标识符由任意顺序的字母、下画线&#xff08;_&#xff09;、美元符号&#xff08;$&#xff09;和数字…

【数据结构】手撕八大排序算法

作者&#xff1a;一个喜欢猫咪的的程序员 专栏&#xff1a;《数据结构》 喜欢的话&#xff1a;世间因为少年的挺身而出&#xff0c;而更加瑰丽。 ——《人民日报》 目录 1.排序的概念&#xff1a; 2.八大排序的思路及其细节 2.1直接插入排序 …

适合编程初学者的开源项目:小游戏2048(安卓Compose版)

目标 为编程初学者打造入门学习项目&#xff0c;使用各种主流编程语言来实现。 2048游戏规则 一共16个单元格&#xff0c;初始时由2或者4构成。 1、手指向一个方向滑动&#xff0c;所有格子会向那个方向运动。 2、相同数字的两个格子&#xff0c;相遇时数字会相加。 3、每次…

SpringMVC面试题

概述 什么是Spring MVC&#xff1f;简单介绍下你对Spring MVC的理解&#xff1f; Spring MVC是一个基于Java的实现了MVC设计模式的请求驱动类型的轻量级Web框架&#xff0c;通过把模型-视图-控制器分离&#xff0c;将web层进行职责解耦&#xff0c;把复杂的web应用分成逻辑清…

如何在Linux上搭建C++开发环境

工欲善其事&#xff0c;必先利其器&#xff01;我们要在Linux上开发C程序&#xff0c;就要先搭建好它的开发环境。 搭建环境步骤安装Linux安装开发工具写一个demo在项目根目录创建一个构建脚本build.sh使用CodeLite IDE打开项目安装Linux Linux的发行版本很多&#xff0c;萝卜…

测试开发——测试分类

目录 一、 有关测试用例的回顾 二、 测试用例的划分 1、 按照测试对象来划分 可靠性测试 容错性测试 内存泄漏测试 弱网测试 2、按照是否查看代码划分 3、按照开发阶段划分 一、 有关测试用例的回顾 万能测试用例设计公式 如何根据需求去设计测试用例&#xff1f; …

计算机视觉OpenCv学习系列:第三部分、滚动条操作

第三部分、滚动条操作第一节、滚动条操作1.事件响应函数&#xff08;1&#xff09;UI组件时间响应过程&#xff08;2&#xff09;事件响应函数&#xff08;3&#xff09;创建窗口函数&#xff08;4&#xff09;调整图像亮度2.滚动条操作3.代码练习与测试学习参考第一节、滚动条…

Python 协程学习有点难度?这篇文字值得你去收藏

Python 协程在基础学习阶段&#xff0c;属于有难度的知识点&#xff0c;建议大家在学习的时候&#xff0c;一定要反复练习。 Python 中的协程是一种用户态的轻量级线程。它与普通的线程不同&#xff0c;普通线程是由操作系统调度的&#xff0c;而协程是由程序自己调度的。因此&…

【ESP 保姆级教程】玩转emqx篇③ ——认证安全之使用内置数据库(Mnesia)的密码认证

忘记过去&#xff0c;超越自己 ❤️ 博客主页 单片机菜鸟哥&#xff0c;一个野生非专业硬件IOT爱好者 ❤️❤️ 本篇创建记录 2023-01-15 ❤️❤️ 本篇更新记录 2022-01-15 ❤️&#x1f389; 欢迎关注 &#x1f50e;点赞 &#x1f44d;收藏 ⭐️留言&#x1f4dd;&#x1f64…

Transformer模型详解相关了解

文章目录Transformer模型详解1.前言1.1 Transformer 整体结构1.2 Transformer 的工作流程2. Transformer 的输入2.1 单词 Embedding2.2 位置 Embedding3. Self-Attention&#xff08;自注意力机制&#xff09;3.1 Self-Attention 结构3.2 Q, K, V 的计算3.3 Self-Attention 的输…

《神经网络与深度学习》 邱希鹏 学习笔记(一)

一、机器学习的基本要素 机器学习的基本要素: 模型 学习准则 优化算法 其中模型分为线性和非线性。学习准则有用损失函数来评价模型的好坏&#xff0c;还有经验风险最小化准则&#xff0c;大概意思就是在平均损失函数中获得最小的损失函数&#xff0c;但是因为样本可能很小&…

Goodbye 2022,Welcome 2023 | 锁定 2023

引言又是一年春来到&#xff0c;新年应比旧年好。旧岁已辞&#xff0c;新年已到&#xff0c;新旧更迭之际&#xff0c;真想剪个头发换身行头&#xff0c;就能重新出发。但终究是要回头看看啊&#xff0c;那一路而来的荆棘与芬芳&#xff0c;才是成长的印记啊。那就回拨记忆&…

和涤生大数据的故事

1自我介绍 大家好&#xff0c;我是泰罗奥特曼&#xff0c;毕业于东北的一所不知名一本大学&#xff0c;学校在一个小城市里面&#xff0c;最热闹的地方是一个四层楼的商城&#xff0c;专业是信息管理与信息系统&#xff0c;由于是调剂的&#xff0c;所以我也不知道这个专业是干…

一篇文章带你学完JavaScript基础知识,超全的JavaScript知识点总结

目录 内置函数 alert警告框 promopt提示框 console控制台 字面量 数字型 字符串型 变量 声明与赋值 类型检测 类型转换 比较运算符 逻辑运算符 条件句 if else switch break,continue while 赋值运算符 函数 关键字形式函数 变量认知 作用域 表达式…