深入Go运行时:数值溢出、浮点精度与栈堆分配决策

13 阅读7分钟

数值

在程序开发过程中,尤其是电商、结算系统中有两个需要重点关注地方值溢出问题和精度问题这个问题不是GO语言程序特有的,Mysql的FLOAT、DOUBLE的列类型都有精度误差问题,PHP中的浮点数不能进行判断等等,现在要整理的是关于GO语言的数值问题。

先思考一个问题,下面的代码会报错么?会输出什么值?

var a int = 1.00
fmt.Println(reflect.TypeOf(a)) //int

a := 1.00
fmt.Println(reflect.TypeOf(a)) //float64

var c int = 'x'
fmt.Println(c, reflect.TypeOf(c))//120 int

都不会报错

第一个数值类型的字面量在不需要四舍五入时,可以用来表示一个整数基本类型的值。

第二个是系统自动获取类型成为了浮点类型,32位操作系统就是float32, 64位操作系统就是float64。

第三个是单引号,在GO语言中'x' 不是字符串,是字符,对应的 ASCII 码值就是 120,程序会自动把字符 'x' 转换成它的数字编码 120,再赋值给 int 变量。

一、有符号整型 (signed)

默认整数类型是 int,带正负号,最高位表示符号(0 正 1 负)。

类型占用字节取值范围说明
int81 字节-128 ~ 127极小整数,节省空间
int162 字节-32768 ~ 32767小整数
int324 字节-2147483648 ~ 2147483647等同于 C 语言 int
int648 字节-9223372036854775808 ~ 9223372036854775807超大整数,通用长整型
int32 位系统:4 字节64 位系统:8 字节32 位系统:同int3264 位系统:同int64Go 最常用整型,平台自适应

二、无符号整型 (unsigned)

仅支持0 和正数,没有符号位,全部位都用来存储数值。

类型占用字节取值范围说明
uint81 字节0 ~ 255等同于 byte,存储字节 / ASCII
uint162 字节0 ~ 65535无符号小整数
uint324 字节0 ~ 4294967295无符号 32 位整数
uint648 字节0 ~ 18446744073709551615无符号超大整数
uint32 位系统:4 字节64 位系统:8 字节32 位系统:同uint3264 位系统:同uint64自适应无符号整型

三、特殊整型

byte 等价于 uint8,专门用于存储字节数据(二进制、字符流),是 Go 的字节类型别名。

rune 等价于 int32,专门用于存储Unicode 字符(中文、 emoji 等),是 Go 的字符类型别名。

数值溢出

无符号整数溢出

以无符号 uint8 为例, 最容易理解,取值范围:0 ~ 255 ,下面我写一下溢出的过程:

计算机的操作系统把所有的程序都以二进制来计算,255的二进制数值11111111,当255+1的时候,程序就变成256,而256的二进制就变成了1 00000000 但 uint8 只能存 8 位!所以计算机直接把最高位的 1 丢掉 , 最终结果就是 0

Go 官方提供专门的使用 math/bits 包检测溢出函数,真正工程里都用这个。

package main

import (
    "fmt"
    "math/bits"
)

func main() {
    var a uint8 = 255
    res, overflow := bits.Add8(a, 1)

    if overflow {
        fmt.Println("溢出了!")
        return
    }
    fmt.Println(res)
}
// 支持的类型:
// bits.Add8/16/32/64
// bits.Sub8/16/32/64
// bits.Mul8/16/32/64

有符号整数溢出

溢出后从最小值重新开始,无符号整数(uintX)溢出时,会自动取模 2ⁿ,从 0 重新循环:

溢出过程:127 的二进制数值为0111 1111,执行 127 + 1等于128,128的二进制结果为 1000 0000,int8 只能存 8 位,没有高位可丢,但符号位翻转了,第 1 位变成 1 代表负数,Go 有符号数用补码存储,1000 0000 对应的十进制就是 -128

func main() {
    var a int8 = 127
    a = a + 1 // 正溢出
    fmt.Println(a) // 输出:-128(直接跳到最小值)

    var b int8 = -128
    b = b - 1 // 负溢出
    fmt.Println(b) // 输出:127(直接跳到最大值)
}

浮点数溢出

先说一个概念,精度在指定范围内,将N位十进制(按照科学计数法表示)与二进制数互转,如果数据不发生损失,就意味着在此范围内有N位精度。

浮点数和整数完全不同, 不是截断位数,而是指数位达到上限,无法表示更大数值 ,变成无穷大 (+Inf) ,这就表示GO语言的浮点数是丢失精度的,计算机的计算方式是二进制的科学计数法,有的小数是不能被2整除,注定有很多小数是无法精度计算的。

但是在GO语言中,有个非常有意思的地方,浮点数的计算是丢失精度的,但常量的计算是准确的,下面的计算,原因是常量在编译时刻就进行估值的,而变量是需要通过浮点数的标准进行转化计算的,所以对常量的精度是没有影响的,你看,这就是知识的魅力,知其然,才能知所以然。

    x := float64(0.1)
    y := float64(0.2)
    fmt.Printf("x+y=%v \n", x+y) //0.30000000000000004 
    fmt.Println("0.1 + 0.2=%v", 0.1+0.2) //0.3

溢出过程:float32 能表示的最大有限数:约 3.4e38,当数值大于3.4e38 时,8 位指数已经到最大值(无法再变大),计算机无法存储更大的正常浮点数,直接标记为 +Inf(正无穷大)

func main() {
    var f float64 = 1e308
    f = f * 10 // 超出范围
    fmt.Println(f) // 输出:+Inf
}

内存管理

了解Go语言的内存管理,得先理解一个内存块的概念,内存块就是一段在运行时刻承载着若干值部的连续内存片段,一个内存块可承载的值部可能不止一个。

什么时候会开辟内存块?

  • 声明变量的时候,我们使用New或者make调用内置的函数,需要注意的是new只会开辟一个内存块,而make函数可能会开辟多个内存块来承载创建的切片、映射、或者是通道,或者是通道的值或者是指针。
number := make([]int,10)
temp := []int{}
name := "stark张宇"
  • 拼接非常量的字符串,如果两个字符串或者是其中一个字符串是变量,两个字符串进行拼接的时候也会开辟新的内存块,为了避免开辟内存块,我们把固定拼接的字符串定义为常量。
  • 字符串转字节切片或字节切片转字符串,将一个整数转换为字符串
  • 调用内置append函数触发切片扩容时
  • 向map中添加元素,且map底层内部的哈希表需要改变容量时

栈(Stack)、堆(Heap)

栈(Stack)是由操作系统自动分配、自动释放的一小块连续内存空间,函数执行时自动分配的内存,速度极快,函数调用时开辟,函数返回时自动释放,内存连续、访问超快,是单个内存块最常放的位置。哪些内存块会在栈上?函数内部声明的基础类型(int、bool、float、string),函数内部的数组、结构体(值类型)。

堆(Heap)需要垃圾回收(GC)的内存,堆是全局共享的内存区域,由 Go 运行时统一管理。哪些内存块会在堆上?使用 new() / make() 创建的值,切片、map、chan 底层数据,被闭包引用的变量,逃逸到函数外的变量(被外部使用、返回指针等)

简单来说,Go的编译器会根据变量是否会发生逃逸,不逃逸存储在栈(Stack)中,逃逸存储在堆(Heap)中。

对比维度栈 Stack堆 Heap
管理者操作系统自动管理开发者 / GC 垃圾回收
内存形态连续空间离散碎片化空间
空间大小小、固定限制大、灵活扩容
生命周期随函数销毁,瞬时释放随引用 / 生命周期决定,长期存在
访问速度
存储内容局部变量、基本类型、引用地址对象、数组、复杂引用类型
所属范围线程私有进程共享
常见问题栈溢出 StackOverflow内存泄漏、OOM 堆溢出